444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|