Refactor shortcuts tree out of navmenu so we can use it in the shortcuts editor
This commit is contained in:
443
packages/web/components/templates/ShortcutsTree.tsx
Normal file
443
packages/web/components/templates/ShortcutsTree.tsx
Normal file
@ -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<TreeApi<Shortcut> | 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<string, boolean>
|
||||
>({
|
||||
key: 'nav-menu-open-state',
|
||||
isSessionStorage: false,
|
||||
initialValue: {},
|
||||
})
|
||||
const tree = useMemo(() => {
|
||||
const result = new SimpleTree<Shortcut>((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<Shortcut>) => {
|
||||
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 (
|
||||
<Box
|
||||
ref={ref}
|
||||
css={{
|
||||
height: maximumHeight,
|
||||
flexGrow: 1,
|
||||
minBlockSize: 0,
|
||||
}}
|
||||
>
|
||||
{!isLoading && (
|
||||
<Tree
|
||||
ref={props.treeRef}
|
||||
data={data as Shortcut[]}
|
||||
onCreate={onCreate}
|
||||
onMove={onMove}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onToggle={onToggle}
|
||||
onActivate={onActivate}
|
||||
rowHeight={36}
|
||||
initialOpenState={folderOpenState}
|
||||
width={width}
|
||||
height={maximumHeight}
|
||||
>
|
||||
{NodeRenderer}
|
||||
</Tree>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeRenderer(args: {
|
||||
style: CSSProperties
|
||||
node: NodeApi<Shortcut>
|
||||
tree: TreeApi<Shortcut>
|
||||
dragHandle?: (el: HTMLDivElement | null) => void
|
||||
preview?: boolean
|
||||
}) {
|
||||
const isSelected = false
|
||||
const [menuVisible, setMenuVisible] = useState(false)
|
||||
const [menuOpened, setMenuOpened] = useState(false)
|
||||
|
||||
return (
|
||||
<HStack
|
||||
ref={args.dragHandle}
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{
|
||||
pl: `${20 + args.node.level * 15}px`,
|
||||
mb: '2px',
|
||||
gap: '10px',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '34px',
|
||||
|
||||
backgroundColor: isSelected ? '$thLibrarySelectionColor' : 'unset',
|
||||
fontSize: '15px',
|
||||
fontWeight: 'regular',
|
||||
fontFamily: '$display',
|
||||
color: isSelected
|
||||
? '$thLibraryMenuSecondary'
|
||||
: '$thLibraryMenuUnselected',
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected
|
||||
? '$thLibrarySelectionColor'
|
||||
: '$thBackground4',
|
||||
},
|
||||
'&:active': {
|
||||
outline: 'unset',
|
||||
backgroundColor: isSelected
|
||||
? '$thLibrarySelectionColor'
|
||||
: '$thBackground4',
|
||||
},
|
||||
'&:hover [role="hover-menu"]': {
|
||||
opacity: '1',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setMenuVisible(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMenuVisible(false)
|
||||
}}
|
||||
title={args.node.data.name}
|
||||
onClick={(e) => {
|
||||
// router.push(`/` + props.section)
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
css={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
>
|
||||
<NodeItemContents node={args.node} />
|
||||
<SpanBox
|
||||
role="hover-menu"
|
||||
css={{
|
||||
display: 'flex',
|
||||
ml: 'auto',
|
||||
mr: '15px',
|
||||
opacity: menuVisible || menuOpened ? '1' : '0',
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
side="bottom"
|
||||
triggerElement={<DotsThree size={20} />}
|
||||
css={{ ml: 'auto' }}
|
||||
onOpenChange={(open) => {
|
||||
setMenuOpened(open)
|
||||
}}
|
||||
>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
args.tree.delete(args.node)
|
||||
}}
|
||||
title="Remove"
|
||||
/>
|
||||
{/* {args.node.data.type == 'folder' && (
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
args.node.data.join = 'or'
|
||||
}}
|
||||
title="Folder query: OR"
|
||||
/>
|
||||
)} */}
|
||||
</Dropdown>
|
||||
</SpanBox>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
type NodeItemContentsProps = {
|
||||
node: NodeApi<Shortcut>
|
||||
}
|
||||
|
||||
const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => {
|
||||
if (props.node.isEditing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
defaultValue={props.node.data.name}
|
||||
onFocus={(e) => 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 (
|
||||
<SpanBox>
|
||||
<FeedOrNewsletterShortcut shortcut={shortcut} />
|
||||
</SpanBox>
|
||||
)
|
||||
case 'label':
|
||||
return (
|
||||
<Box>
|
||||
<LabelShortcut shortcut={shortcut} />
|
||||
</Box>
|
||||
)
|
||||
case 'search':
|
||||
return (
|
||||
<Box>
|
||||
<SearchShortcut shortcut={shortcut} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ gap: '10px', width: '100%' }}
|
||||
onClick={(event) => {
|
||||
props.node.toggle()
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.node.isClosed ? (
|
||||
<ShortcutFolderClosed
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
) : (
|
||||
<ShortcutFolderOpen
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
)}
|
||||
{props.node.data.name}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}
|
||||
|
||||
type ShortcutItemProps = {
|
||||
shortcut: Shortcut
|
||||
}
|
||||
|
||||
const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => {
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{ pl: '10px', width: '100%', gap: '10px' }}
|
||||
key={`search-${props.shortcut.id}`}
|
||||
>
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ minWidth: '20px' }}
|
||||
>
|
||||
{props.shortcut.icon ? (
|
||||
<CoverImage
|
||||
src={props.shortcut.icon}
|
||||
width={20}
|
||||
height={20}
|
||||
css={{ borderRadius: '20px' }}
|
||||
/>
|
||||
) : props.shortcut.type == 'newsletter' ? (
|
||||
<NewsletterIcon color="#F59932" size={18} />
|
||||
) : (
|
||||
<FollowingIcon color="#F59932" size={21} />
|
||||
)}
|
||||
</HStack>
|
||||
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchShortcut = (props: ShortcutItemProps): JSX.Element => {
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{ pl: '10px', width: '100%', gap: '7px' }}
|
||||
key={`search-${props.shortcut.id}`}
|
||||
>
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ minWidth: '20px' }}
|
||||
>
|
||||
<ListMagnifyingGlass size={17} />
|
||||
</HStack>
|
||||
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const LabelShortcut = (props: ShortcutItemProps): JSX.Element => {
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{ width: '100%', gap: '7px' }}
|
||||
key={`search-${props.shortcut.id}`}
|
||||
>
|
||||
<Tag
|
||||
size={15}
|
||||
color={props.shortcut.label?.color ?? 'gray'}
|
||||
weight="fill"
|
||||
/>
|
||||
<StyledText style="settingsItem" css={{ pb: '1px' }}>
|
||||
{props.shortcut.name}
|
||||
</StyledText>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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<TreeApi<Shortcut> | undefined>(undefined)
|
||||
const resetShortcuts = useResetShortcuts()
|
||||
|
||||
@ -321,6 +285,12 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
|
||||
triggerElement={<DotsThree size={20} />}
|
||||
css={{ ml: 'auto' }}
|
||||
>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
router.push(`/settings/shortcuts`)
|
||||
}}
|
||||
title="Edit shortcuts"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={resetShortcutsToDefault}
|
||||
title="Reset to default"
|
||||
@ -350,514 +320,6 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
|
||||
)
|
||||
}
|
||||
|
||||
type ShortcutsTreeProps = {
|
||||
treeRef: React.MutableRefObject<TreeApi<Shortcut> | undefined>
|
||||
}
|
||||
|
||||
async function getShortcuts(path: string): Promise<Shortcut[]> {
|
||||
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<Shortcut[]> {
|
||||
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<Shortcut[]> {
|
||||
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<string, boolean>
|
||||
>({
|
||||
key: 'nav-menu-open-state',
|
||||
isSessionStorage: false,
|
||||
initialValue: {},
|
||||
})
|
||||
const tree = useMemo(() => {
|
||||
const result = new SimpleTree<Shortcut>((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<Shortcut>) => {
|
||||
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 (
|
||||
<Box
|
||||
ref={ref}
|
||||
css={{
|
||||
height: maximumHeight,
|
||||
flexGrow: 1,
|
||||
minBlockSize: 0,
|
||||
}}
|
||||
>
|
||||
{!isLoading && (
|
||||
<Tree
|
||||
ref={props.treeRef}
|
||||
data={data as Shortcut[]}
|
||||
onCreate={onCreate}
|
||||
onMove={onMove}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onToggle={onToggle}
|
||||
onActivate={onActivate}
|
||||
rowHeight={36}
|
||||
initialOpenState={folderOpenState}
|
||||
width={width}
|
||||
height={maximumHeight}
|
||||
>
|
||||
{NodeRenderer}
|
||||
</Tree>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeRenderer(args: {
|
||||
style: CSSProperties
|
||||
node: NodeApi<Shortcut>
|
||||
tree: TreeApi<Shortcut>
|
||||
dragHandle?: (el: HTMLDivElement | null) => void
|
||||
preview?: boolean
|
||||
}) {
|
||||
const isSelected = false
|
||||
const [menuVisible, setMenuVisible] = useState(false)
|
||||
const [menuOpened, setMenuOpened] = useState(false)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<HStack
|
||||
ref={args.dragHandle}
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{
|
||||
pl: `${20 + args.node.level * 15}px`,
|
||||
mb: '2px',
|
||||
gap: '10px',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '34px',
|
||||
|
||||
backgroundColor: isSelected ? '$thLibrarySelectionColor' : 'unset',
|
||||
fontSize: '15px',
|
||||
fontWeight: 'regular',
|
||||
fontFamily: '$display',
|
||||
color: isSelected
|
||||
? '$thLibraryMenuSecondary'
|
||||
: '$thLibraryMenuUnselected',
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected
|
||||
? '$thLibrarySelectionColor'
|
||||
: '$thBackground4',
|
||||
},
|
||||
'&:active': {
|
||||
outline: 'unset',
|
||||
backgroundColor: isSelected
|
||||
? '$thLibrarySelectionColor'
|
||||
: '$thBackground4',
|
||||
},
|
||||
'&:hover [role="hover-menu"]': {
|
||||
opacity: '1',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setMenuVisible(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMenuVisible(false)
|
||||
}}
|
||||
title={args.node.data.name}
|
||||
onClick={(e) => {
|
||||
// router.push(`/` + props.section)
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
css={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
>
|
||||
<NodeItemContents node={args.node} />
|
||||
<SpanBox
|
||||
role="hover-menu"
|
||||
css={{
|
||||
display: 'flex',
|
||||
ml: 'auto',
|
||||
mr: '15px',
|
||||
opacity: menuVisible || menuOpened ? '1' : '0',
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
side="bottom"
|
||||
triggerElement={<DotsThree size={20} />}
|
||||
css={{ ml: 'auto' }}
|
||||
onOpenChange={(open) => {
|
||||
setMenuOpened(open)
|
||||
}}
|
||||
>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
args.tree.delete(args.node)
|
||||
}}
|
||||
title="Remove"
|
||||
/>
|
||||
{/* {args.node.data.type == 'folder' && (
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
args.node.data.join = 'or'
|
||||
}}
|
||||
title="Folder query: OR"
|
||||
/>
|
||||
)} */}
|
||||
</Dropdown>
|
||||
</SpanBox>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
type NodeItemContentsProps = {
|
||||
node: NodeApi<Shortcut>
|
||||
}
|
||||
|
||||
const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => {
|
||||
if (props.node.isEditing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
defaultValue={props.node.data.name}
|
||||
onFocus={(e) => 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 (
|
||||
<SpanBox>
|
||||
<FeedOrNewsletterShortcut shortcut={shortcut} />
|
||||
</SpanBox>
|
||||
)
|
||||
case 'label':
|
||||
return (
|
||||
<Box>
|
||||
<LabelShortcut shortcut={shortcut} />
|
||||
</Box>
|
||||
)
|
||||
case 'search':
|
||||
return (
|
||||
<Box>
|
||||
<SearchShortcut shortcut={shortcut} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ gap: '10px', width: '100%' }}
|
||||
onClick={(event) => {
|
||||
props.node.toggle()
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.node.isClosed ? (
|
||||
<ShortcutFolderClosed
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
) : (
|
||||
<ShortcutFolderOpen
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
)}
|
||||
{props.node.data.name}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}
|
||||
|
||||
type ShortcutItemProps = {
|
||||
shortcut: Shortcut
|
||||
}
|
||||
|
||||
const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => {
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{ pl: '10px', width: '100%', gap: '10px' }}
|
||||
key={`search-${props.shortcut.id}`}
|
||||
>
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ minWidth: '20px' }}
|
||||
>
|
||||
{props.shortcut.icon ? (
|
||||
<CoverImage
|
||||
src={props.shortcut.icon}
|
||||
width={20}
|
||||
height={20}
|
||||
css={{ borderRadius: '20px' }}
|
||||
/>
|
||||
) : props.shortcut.type == 'newsletter' ? (
|
||||
<NewsletterIcon color="#F59932" size={18} />
|
||||
) : (
|
||||
<FollowingIcon color="#F59932" size={21} />
|
||||
)}
|
||||
</HStack>
|
||||
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchShortcut = (props: ShortcutItemProps): JSX.Element => {
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{ pl: '10px', width: '100%', gap: '7px' }}
|
||||
key={`search-${props.shortcut.id}`}
|
||||
>
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ minWidth: '20px' }}
|
||||
>
|
||||
<ListMagnifyingGlass size={17} />
|
||||
</HStack>
|
||||
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const LabelShortcut = (props: ShortcutItemProps): JSX.Element => {
|
||||
// <OutlinedLabelChip
|
||||
// text={props.shortcut.name}
|
||||
// color={props.shortcut.label?.color ?? 'gray'}
|
||||
// />
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{ width: '100%', gap: '7px' }}
|
||||
key={`search-${props.shortcut.id}`}
|
||||
>
|
||||
<Tag
|
||||
size={15}
|
||||
color={props.shortcut.label?.color ?? 'gray'}
|
||||
weight="fill"
|
||||
/>
|
||||
<StyledText style="settingsItem" css={{ pb: '1px' }}>
|
||||
{props.shortcut.name}
|
||||
</StyledText>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
type NavButtonProps = {
|
||||
text: string
|
||||
icon: ReactNode
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' ? (
|
||||
<CheckSquare size={20} weight="duotone" />
|
||||
) : (
|
||||
<Square size={20} weight="duotone" />
|
||||
)}
|
||||
<StyledText style="modalTitle" css={{}}>
|
||||
Enable shortcuts
|
||||
</StyledText>
|
||||
</HStack>
|
||||
></HStack>
|
||||
<Box
|
||||
css={{
|
||||
py: '$3',
|
||||
|
||||
Reference in New Issue
Block a user