From 332583d5c11f5f765c4cde9d6ac91fbc61eff274 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 31 Jul 2024 16:20:57 +0800 Subject: [PATCH] Refactor shortcuts tree out of navmenu so we can use it in the shortcuts editor --- .../components/templates/ShortcutsTree.tsx | 443 ++++++++++++++ .../templates/navMenu/LibraryMenu.tsx | 2 +- .../templates/navMenu/NavigationMenu.tsx | 562 +----------------- .../lib/networking/shortcuts/useShortcuts.tsx | 20 +- packages/web/pages/settings/shortcuts.tsx | 14 +- 5 files changed, 478 insertions(+), 563 deletions(-) create mode 100644 packages/web/components/templates/ShortcutsTree.tsx diff --git a/packages/web/components/templates/ShortcutsTree.tsx b/packages/web/components/templates/ShortcutsTree.tsx new file mode 100644 index 000000000..1e183a220 --- /dev/null +++ b/packages/web/components/templates/ShortcutsTree.tsx @@ -0,0 +1,443 @@ +import { useRouter } from 'next/router' +import { NodeApi, SimpleTree, Tree, TreeApi } from 'react-arborist' +import useResizeObserver from 'use-resize-observer' +import { + Shortcut, + useGetShortcuts, + useSetShortcuts, +} from '../../lib/networking/shortcuts/useShortcuts' +import { usePersistedState } from '../../lib/hooks/usePersistedState' +import { CSSProperties, useCallback, useMemo, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' +import { Box, HStack, SpanBox } from '../elements/LayoutPrimitives' +import { Dropdown, DropdownOption } from '../elements/DropdownElements' +import { DotsThree, ListMagnifyingGlass, Tag } from '@phosphor-icons/react' +import { ShortcutFolderClosed } from '../elements/icons/ShortcutFolderClosed' +import { theme } from '../tokens/stitches.config' +import { ShortcutFolderOpen } from '../elements/icons/ShortcutFolderOpen' +import { CoverImage } from '../elements/CoverImage' +import { NewsletterIcon } from '../elements/icons/NewsletterIcon' +import { FollowingIcon } from '../elements/icons/FollowingIcon' +import { StyledText } from '../elements/StyledText' +import { OpenMap } from 'react-arborist/dist/module/state/open-slice' + +type ShortcutsTreeProps = { + treeRef: React.MutableRefObject | undefined> +} + +export const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { + const router = useRouter() + const { ref, width, height } = useResizeObserver() + const { data, isLoading } = useGetShortcuts() + const setShorcuts = useSetShortcuts() + + const [folderOpenState, setFolderOpenState] = usePersistedState< + Record + >({ + key: 'nav-menu-open-state', + isSessionStorage: false, + initialValue: {}, + }) + const tree = useMemo(() => { + const result = new SimpleTree((data ?? []) as Shortcut[]) + return result + }, [data]) + + const syncTreeData = async (data: Shortcut[]) => { + await setShorcuts.mutateAsync({ shortcuts: data }) + } + + const onMove = useCallback( + async (args: { + dragIds: string[] + parentId: null | string + index: number + }) => { + for (const id of args.dragIds) { + tree?.move({ id, parentId: args.parentId, index: args.index }) + } + await syncTreeData(tree.data) + }, + [tree, data] + ) + + const onCreate = useCallback( + async (args: { parentId: string | null; index: number; type: string }) => { + const data = { id: uuidv4(), name: '', type: 'folder' } as any + if (args.type === 'internal') { + data.children = [] + } + tree.create({ parentId: args.parentId, index: args.index, data }) + await syncTreeData(tree.data) + return data + }, + [tree, data] + ) + + const onDelete = useCallback( + async (args: { ids: string[] }) => { + args.ids.forEach((id) => tree.drop({ id })) + await syncTreeData(tree.data) + }, + [tree, data] + ) + + const onRename = useCallback( + async (args: { name: string; id: string }) => { + tree.update({ id: args.id, changes: { name: args.name } as any }) + await syncTreeData(tree.data) + }, + [tree, data] + ) + + const onToggle = useCallback( + (id: string) => { + if (id && props.treeRef.current) { + const isOpen = props.treeRef.current?.isOpen(id) + const newItem: OpenMap = {} + newItem[id] = isOpen + setFolderOpenState({ ...folderOpenState, ...newItem }) + } + }, + [props, folderOpenState, setFolderOpenState] + ) + + const onActivate = useCallback( + (node: NodeApi) => { + if (node.data.type == 'folder') { + const join = node.data.join + if (join == 'or') { + const query = node.children + ?.map((child) => { + return `(${child.data.filter})` + }) + .join(' OR ') + } + } else if (node.data.section != null && node.data.filter != null) { + router.push(`/${node.data.section}?q=${node.data.filter}`) + } + }, + [tree, router] + ) + + function countTotalShortcuts(shortcuts: Shortcut[]): number { + let total = 0 + + for (const shortcut of shortcuts) { + // Count the current shortcut + total++ + + // If the shortcut has children, recursively count them + if (shortcut.children && shortcut.children.length > 0) { + total += countTotalShortcuts(shortcut.children) + } + } + + return total + } + + const maximumHeight = useMemo(() => { + if (!data) { + return 320 + } + return countTotalShortcuts(data as Shortcut[]) * 36 + }, [data]) + + return ( + + {!isLoading && ( + + {NodeRenderer} + + )} + + ) +} + +function NodeRenderer(args: { + style: CSSProperties + node: NodeApi + tree: TreeApi + dragHandle?: (el: HTMLDivElement | null) => void + preview?: boolean +}) { + const isSelected = false + const [menuVisible, setMenuVisible] = useState(false) + const [menuOpened, setMenuOpened] = useState(false) + + return ( + { + setMenuVisible(true) + }} + onMouseLeave={() => { + setMenuVisible(false) + }} + title={args.node.data.name} + onClick={(e) => { + // router.push(`/` + props.section) + }} + > + + + + } + css={{ ml: 'auto' }} + onOpenChange={(open) => { + setMenuOpened(open) + }} + > + { + args.tree.delete(args.node) + }} + title="Remove" + /> + {/* {args.node.data.type == 'folder' && ( + { + args.node.data.join = 'or' + }} + title="Folder query: OR" + /> + )} */} + + + + + ) +} + +type NodeItemContentsProps = { + node: NodeApi +} + +const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => { + if (props.node.isEditing) { + return ( + e.currentTarget.select()} + onBlur={() => props.node.reset()} + onKeyDown={(e) => { + if (e.key === 'Escape') { + props.node.reset() + } + if (e.key === 'Enter') { + // props.node.data = { + // id: 'new-folder', + // type: 'folder', + // name: e.currentTarget.value, + // } + props.node.submit(e.currentTarget.value) + props.node.activate() + } + }} + /> + ) + } + if (props.node.isLeaf) { + const shortcut = props.node.data + if (shortcut) { + switch (shortcut.type) { + case 'feed': + case 'newsletter': + return ( + + + + ) + case 'label': + return ( + + + + ) + case 'search': + return ( + + + + ) + } + } + } else { + return ( + { + props.node.toggle() + event.preventDefault() + }} + > + {props.node.isClosed ? ( + + ) : ( + + )} + {props.node.data.name} + + ) + } + return <> +} + +type ShortcutItemProps = { + shortcut: Shortcut +} + +const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => { + return ( + + + {props.shortcut.icon ? ( + + ) : props.shortcut.type == 'newsletter' ? ( + + ) : ( + + )} + + {props.shortcut.name} + + ) +} + +const SearchShortcut = (props: ShortcutItemProps): JSX.Element => { + return ( + + + + + {props.shortcut.name} + + ) +} + +const LabelShortcut = (props: ShortcutItemProps): JSX.Element => { + return ( + + + + {props.shortcut.name} + + + ) +} diff --git a/packages/web/components/templates/navMenu/LibraryMenu.tsx b/packages/web/components/templates/navMenu/LibraryMenu.tsx index aa8313794..f23589e96 100644 --- a/packages/web/components/templates/navMenu/LibraryMenu.tsx +++ b/packages/web/components/templates/navMenu/LibraryMenu.tsx @@ -22,13 +22,13 @@ import { HomeIcon } from '../../elements/icons/HomeIcon' import { LibraryIcon } from '../../elements/icons/LibraryIcon' import { HighlightsIcon } from '../../elements/icons/HighlightsIcon' import { CoverImage } from '../../elements/CoverImage' -import { Shortcut } from './NavigationMenu' import { OutlinedLabelChip } from '../../elements/OutlinedLabelChip' import { NewsletterIcon } from '../../elements/icons/NewsletterIcon' import { Dropdown, DropdownOption } from '../../elements/DropdownElements' import { useRouter } from 'next/router' import { DiscoverIcon } from '../../elements/icons/DiscoverIcon' import { escapeQuotes } from '../../../utils/helper' +import { Shortcut } from '../../../lib/networking/shortcuts/useShortcuts' export const LIBRARY_LEFT_MENU_WIDTH = '275px' diff --git a/packages/web/components/templates/navMenu/NavigationMenu.tsx b/packages/web/components/templates/navMenu/NavigationMenu.tsx index 81bb31bac..797470e20 100644 --- a/packages/web/components/templates/navMenu/NavigationMenu.tsx +++ b/packages/web/components/templates/navMenu/NavigationMenu.tsx @@ -1,16 +1,8 @@ -import { - CSSProperties, - ReactNode, - useCallback, - useMemo, - useRef, - useState, -} from 'react' +import { ReactNode, useCallback, useRef, useState } from 'react' import { StyledText } from '../../elements/StyledText' import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { Button } from '../../elements/Button' -import { DotsThree, List, X, Tag } from '@phosphor-icons/react' -import { Label } from '../../../lib/networking/fragments/labelFragment' +import { DotsThree } from '@phosphor-icons/react' import { theme } from '../../tokens/stitches.config' import { usePersistedState } from '../../../lib/hooks/usePersistedState' import { NavMenuFooter } from './Footer' @@ -18,52 +10,23 @@ import { FollowingIcon } from '../../elements/icons/FollowingIcon' import { HomeIcon } from '../../elements/icons/HomeIcon' import { LibraryIcon } from '../../elements/icons/LibraryIcon' import { HighlightsIcon } from '../../elements/icons/HighlightsIcon' -import { CoverImage } from '../../elements/CoverImage' -import { NewsletterIcon } from '../../elements/icons/NewsletterIcon' import { Dropdown, DropdownOption } from '../../elements/DropdownElements' import { useRouter } from 'next/router' import { NavigationSection } from '../NavigationLayout' -import { NodeApi, SimpleTree, Tree, TreeApi } from 'react-arborist' -import { ListMagnifyingGlass } from '@phosphor-icons/react' +import { TreeApi } from 'react-arborist' import React from 'react' -import { fetchEndpoint } from '../../../lib/appConfig' -import { requestHeaders } from '../../../lib/networking/networkHelpers' -import { v4 as uuidv4 } from 'uuid' -import { showErrorToast } from '../../../lib/toastHelpers' -import { OpenMap } from 'react-arborist/dist/module/state/open-slice' import { ArchiveSectionIcon } from '../../elements/icons/ArchiveSectionIcon' import { NavMoreButtonDownIcon } from '../../elements/icons/NavMoreButtonDown' import { NavMoreButtonUpIcon } from '../../elements/icons/NavMoreButtonUp' -import { ShortcutFolderClosed } from '../../elements/icons/ShortcutFolderClosed' import { TrashSectionIcon } from '../../elements/icons/TrashSectionIcon' -import { ShortcutFolderOpen } from '../../elements/icons/ShortcutFolderOpen' -import useResizeObserver from 'use-resize-observer' import { - useGetShortcuts, + Shortcut, useResetShortcuts, - useSetShortcuts, } from '../../../lib/networking/shortcuts/useShortcuts' +import { ShortcutsTree } from '../ShortcutsTree' export const LIBRARY_LEFT_MENU_WIDTH = '275px' -export type ShortcutType = 'search' | 'label' | 'newsletter' | 'feed' | 'folder' - -export type Shortcut = { - type: ShortcutType - - id: string - name: string - section: string - filter: string - - icon?: string - label?: Label - - join?: string - - children?: Shortcut[] -} - type NavigationMenuProps = { section: NavigationSection @@ -270,6 +233,7 @@ const LibraryNav = (props: NavigationMenuProps): JSX.Element => { } const Shortcuts = (props: NavigationMenuProps): JSX.Element => { + const router = useRouter() const treeRef = useRef | undefined>(undefined) const resetShortcuts = useResetShortcuts() @@ -321,6 +285,12 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => { triggerElement={} css={{ ml: 'auto' }} > + { + router.push(`/settings/shortcuts`) + }} + title="Edit shortcuts" + /> { ) } -type ShortcutsTreeProps = { - treeRef: React.MutableRefObject | undefined> -} - -async function getShortcuts(path: string): Promise { - const url = new URL(path, fetchEndpoint) - try { - const response = await fetch(url.toString(), { - method: 'GET', - headers: requestHeaders(), - credentials: 'include', - mode: 'cors', - }) - const payload = await response.json() - if ('shortcuts' in payload) { - return payload['shortcuts'] as Shortcut[] - } - return [] - } catch (err) { - console.log('error getting shortcuts: ', err) - throw err - } -} - -async function setShortcuts( - path: string, - { arg }: { arg: { shortcuts: Shortcut[] } } -): Promise { - const url = new URL(path, fetchEndpoint) - try { - const response = await fetch(url.toString(), { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - ...requestHeaders(), - }, - credentials: 'include', - mode: 'cors', - body: JSON.stringify(arg), - }) - const payload = await response.json() - if (!('shortcuts' in payload)) { - throw new Error('Error syncing shortcuts') - } - return payload['shortcuts'] as Shortcut[] - } catch (err) { - showErrorToast('Error syncing shortcut changes.') - } - return arg.shortcuts -} - -async function resetShortcuts(path: string): Promise { - const url = new URL(path, fetchEndpoint) - try { - const response = await fetch(url.toString(), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - ...requestHeaders(), - }, - credentials: 'include', - mode: 'cors', - }) - const payload = await response.json() - if (!('shortcuts' in payload)) { - throw new Error('Error syncing shortcuts') - } - return payload['shortcuts'] as Shortcut[] - } catch (err) { - showErrorToast('Error syncing shortcut changes.') - } - return [] -} - -const cachedShortcutsData = (): Shortcut[] | undefined => { - if (typeof localStorage !== 'undefined') { - const str = localStorage.getItem('/api/shortcuts') - if (str) { - return JSON.parse(str) as Shortcut[] - } - } - return undefined -} - -const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { - const router = useRouter() - const { ref, width, height } = useResizeObserver() - const { data, isLoading } = useGetShortcuts() - const setShorcuts = useSetShortcuts() - const resetShortcuts = useResetShortcuts() - - const [folderOpenState, setFolderOpenState] = usePersistedState< - Record - >({ - key: 'nav-menu-open-state', - isSessionStorage: false, - initialValue: {}, - }) - const tree = useMemo(() => { - const result = new SimpleTree((data ?? []) as Shortcut[]) - return result - }, [data]) - - const syncTreeData = async (data: Shortcut[]) => { - await setShorcuts.mutateAsync({ shortcuts: data }) - } - - const onMove = useCallback( - async (args: { - dragIds: string[] - parentId: null | string - index: number - }) => { - for (const id of args.dragIds) { - tree?.move({ id, parentId: args.parentId, index: args.index }) - } - await syncTreeData(tree.data) - }, - [tree, data] - ) - - const onCreate = useCallback( - async (args: { parentId: string | null; index: number; type: string }) => { - const data = { id: uuidv4(), name: '', type: 'folder' } as any - if (args.type === 'internal') { - data.children = [] - } - tree.create({ parentId: args.parentId, index: args.index, data }) - await syncTreeData(tree.data) - return data - }, - [tree, data] - ) - - const onDelete = useCallback( - async (args: { ids: string[] }) => { - args.ids.forEach((id) => tree.drop({ id })) - await syncTreeData(tree.data) - }, - [tree, data] - ) - - const onRename = useCallback( - async (args: { name: string; id: string }) => { - tree.update({ id: args.id, changes: { name: args.name } as any }) - await syncTreeData(tree.data) - }, - [tree, data] - ) - - const onToggle = useCallback( - (id: string) => { - if (id && props.treeRef.current) { - const isOpen = props.treeRef.current?.isOpen(id) - const newItem: OpenMap = {} - newItem[id] = isOpen - setFolderOpenState({ ...folderOpenState, ...newItem }) - } - }, - [props, folderOpenState, setFolderOpenState] - ) - - const onActivate = useCallback( - (node: NodeApi) => { - if (node.data.type == 'folder') { - const join = node.data.join - if (join == 'or') { - const query = node.children - ?.map((child) => { - return `(${child.data.filter})` - }) - .join(' OR ') - } - } else if (node.data.section != null && node.data.filter != null) { - router.push(`/${node.data.section}?q=${node.data.filter}`) - } - }, - [tree, router] - ) - - function countTotalShortcuts(shortcuts: Shortcut[]): number { - let total = 0 - - for (const shortcut of shortcuts) { - // Count the current shortcut - total++ - - // If the shortcut has children, recursively count them - if (shortcut.children && shortcut.children.length > 0) { - total += countTotalShortcuts(shortcut.children) - } - } - - return total - } - - const maximumHeight = useMemo(() => { - if (!data) { - return 320 - } - return countTotalShortcuts(data as Shortcut[]) * 36 - }, [data]) - - return ( - - {!isLoading && ( - - {NodeRenderer} - - )} - - ) -} - -function NodeRenderer(args: { - style: CSSProperties - node: NodeApi - tree: TreeApi - dragHandle?: (el: HTMLDivElement | null) => void - preview?: boolean -}) { - const isSelected = false - const [menuVisible, setMenuVisible] = useState(false) - const [menuOpened, setMenuOpened] = useState(false) - - const router = useRouter() - - return ( - { - setMenuVisible(true) - }} - onMouseLeave={() => { - setMenuVisible(false) - }} - title={args.node.data.name} - onClick={(e) => { - // router.push(`/` + props.section) - }} - > - - - - } - css={{ ml: 'auto' }} - onOpenChange={(open) => { - setMenuOpened(open) - }} - > - { - args.tree.delete(args.node) - }} - title="Remove" - /> - {/* {args.node.data.type == 'folder' && ( - { - args.node.data.join = 'or' - }} - title="Folder query: OR" - /> - )} */} - - - - - ) -} - -type NodeItemContentsProps = { - node: NodeApi -} - -const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => { - if (props.node.isEditing) { - return ( - e.currentTarget.select()} - onBlur={() => props.node.reset()} - onKeyDown={(e) => { - if (e.key === 'Escape') { - props.node.reset() - } - if (e.key === 'Enter') { - // props.node.data = { - // id: 'new-folder', - // type: 'folder', - // name: e.currentTarget.value, - // } - props.node.submit(e.currentTarget.value) - props.node.activate() - } - }} - /> - ) - } - if (props.node.isLeaf) { - const shortcut = props.node.data - if (shortcut) { - switch (shortcut.type) { - case 'feed': - case 'newsletter': - return ( - - - - ) - case 'label': - return ( - - - - ) - case 'search': - return ( - - - - ) - } - } - } else { - return ( - { - props.node.toggle() - event.preventDefault() - }} - > - {props.node.isClosed ? ( - - ) : ( - - )} - {props.node.data.name} - - ) - } - return <> -} - -type ShortcutItemProps = { - shortcut: Shortcut -} - -const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => { - return ( - - - {props.shortcut.icon ? ( - - ) : props.shortcut.type == 'newsletter' ? ( - - ) : ( - - )} - - {props.shortcut.name} - - ) -} - -const SearchShortcut = (props: ShortcutItemProps): JSX.Element => { - return ( - - - - - {props.shortcut.name} - - ) -} - -const LabelShortcut = (props: ShortcutItemProps): JSX.Element => { - // - return ( - - - - {props.shortcut.name} - - - ) -} - type NavButtonProps = { text: string icon: ReactNode diff --git a/packages/web/lib/networking/shortcuts/useShortcuts.tsx b/packages/web/lib/networking/shortcuts/useShortcuts.tsx index f9d15cab6..90c489167 100644 --- a/packages/web/lib/networking/shortcuts/useShortcuts.tsx +++ b/packages/web/lib/networking/shortcuts/useShortcuts.tsx @@ -1,7 +1,25 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestHeaders } from '../networkHelpers' import { fetchEndpoint } from '../../appConfig' -import { Shortcut } from '../../../components/templates/navMenu/NavigationMenu' +import { Label } from '../fragments/labelFragment' + +export type ShortcutType = 'search' | 'label' | 'newsletter' | 'feed' | 'folder' + +export type Shortcut = { + type: ShortcutType + + id: string + name: string + section: string + filter: string + + icon?: string + label?: Label + + join?: string + + children?: Shortcut[] +} export function useGetShortcuts() { return useQuery({ diff --git a/packages/web/pages/settings/shortcuts.tsx b/packages/web/pages/settings/shortcuts.tsx index c42fff53c..0d8169eba 100644 --- a/packages/web/pages/settings/shortcuts.tsx +++ b/packages/web/pages/settings/shortcuts.tsx @@ -33,9 +33,10 @@ import { Button } from '../../components/elements/Button' import { styled } from '@stitches/react' import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment' import { escapeQuotes } from '../../utils/helper' -import { Shortcut } from '../../components/templates/navMenu/NavigationMenu' import { useGetLabels } from '../../lib/networking/labels/useLabels' import { useGetSavedSearches } from '../../lib/networking/savedsearches/useSavedSearches' +import { Shortcut } from '../../lib/networking/shortcuts/useShortcuts' + type ListAction = 'RESET' | 'ADD_ITEM' | 'REMOVE_ITEM' const SHORTCUTS_KEY = 'library-shortcuts' @@ -181,16 +182,7 @@ export default function Shortcuts(): JSX.Element { ) event.preventDefault() }} - > - {navMenuStyle === 'shortcuts' ? ( - - ) : ( - - )} - - Enable shortcuts - - + >