From 7d996a10cc9b5fd6a5f78057b80460f1c0062305 Mon Sep 17 00:00:00 2001 From: nikec <43032218+niikeec@users.noreply.github.com> Date: Thu, 30 Mar 2023 05:11:21 +0200 Subject: [PATCH] [Desktop] Menu tag assignment (#647) * [Desktop] Improve explorer item context menu tags * [Desktop] Add tag assignment to file inspector * clean up * Update tags/CreateDialog.tsx --------- Co-authored-by: Brendan Allan --- .../Explorer/AssignTagMenuItems.tsx | 113 ++++++++++++++++++ .../$libraryId/Explorer/File/ContextMenu.tsx | 66 ++-------- .../$libraryId/Explorer/Inspector/index.tsx | 14 ++- .../settings/library/tags/CreateDialog.tsx | 15 ++- interface/hooks/useScrolled.tsx | 20 ++++ packages/ui/src/ContextMenu.tsx | 13 +- packages/ui/src/DropdownMenu.tsx | 14 ++- packages/ui/src/index.ts | 4 +- 8 files changed, 187 insertions(+), 72 deletions(-) create mode 100644 interface/app/$libraryId/Explorer/AssignTagMenuItems.tsx create mode 100644 interface/hooks/useScrolled.tsx diff --git a/interface/app/$libraryId/Explorer/AssignTagMenuItems.tsx b/interface/app/$libraryId/Explorer/AssignTagMenuItems.tsx new file mode 100644 index 000000000..02c87cf47 --- /dev/null +++ b/interface/app/$libraryId/Explorer/AssignTagMenuItems.tsx @@ -0,0 +1,113 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import clsx from 'clsx'; +import { Plus } from 'phosphor-react'; +import { useRef } from 'react'; +import { useLibraryMutation, useLibraryQuery, usePlausibleEvent } from '@sd/client'; +import { ContextMenu, DropdownMenu, dialogManager, useContextMenu, useDropdownMenu } from '@sd/ui'; +import { useScrolled } from '~/hooks/useScrolled'; +import { usePlatform } from '~/util/Platform'; +import CreateDialog from '../settings/library/tags/CreateDialog'; + +export default (props: { objectId: number }) => { + const platform = usePlatform(); + const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform }); + + const tags = useLibraryQuery(['tags.list'], { suspense: true }); + const tagsForObject = useLibraryQuery(['tags.getForObject', props.objectId], { suspense: true }); + + const assignTag = useLibraryMutation('tags.assign', { + onSuccess: () => { + submitPlausibleEvent({ event: { type: 'tagAssign' } }); + } + }); + + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: tags.data?.length || 0, + getScrollElement: () => parentRef.current, + estimateSize: () => 30, + paddingStart: 2 + }); + + const { isScrolled } = useScrolled(parentRef, 10); + + const isDropdownMenu = useDropdownMenu(); + const isContextMenu = useContextMenu(); + const Menu = isDropdownMenu ? DropdownMenu : isContextMenu ? ContextMenu : undefined; + + if (!Menu) return null; + return ( + <> + { + dialogManager.create((dp) => ); + }} + /> + + {tags.data && tags.data.length > 0 ? ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const tag = tags.data[virtualRow.index]; + const active = !!tagsForObject.data?.find((t) => t.id === tag?.id); + + if (!tag) return null; + return ( + { + e.preventDefault(); + assignTag.mutate({ + tag_id: tag.id, + object_id: props.objectId, + unassign: active + }); + }} + > +
+ {tag.name} + + ); + })} +
+
+ ) : ( +
+ {tags.data ? 'No tags' : 'Failed to load tags'} +
+ )} + + ); +}; diff --git a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx index 1f232fc2c..b3b4e7cb0 100644 --- a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx @@ -18,14 +18,14 @@ import { isObject, useLibraryContext, useLibraryMutation, - useLibraryQuery, - usePlausibleEvent + useLibraryQuery } from '@sd/client'; -import { ContextMenu, Input, dialogManager } from '@sd/ui'; +import { ContextMenu, dialogManager } from '@sd/ui'; import { useExplorerParams } from '~/app/$libraryId/location/$id'; import { showAlertDialog } from '~/components/AlertDialog'; import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; import { usePlatform } from '~/util/Platform'; +import AssignTagMenuItems from '../AssignTagMenuItems'; import { OpenInNativeExplorer } from '../ContextMenu'; import DecryptDialog from './DecryptDialog'; import DeleteDialog from './DeleteDialog'; @@ -158,9 +158,11 @@ export default ({ data, ...props }: Props) => { - - - + {objectData && ( + + + + )} {
); }; - -const AssignTagMenuItems = (props: { objectId: number }) => { - const platform = usePlatform(); - const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform }); - - const tags = useLibraryQuery(['tags.list'], { suspense: true }); - const tagsForObject = useLibraryQuery(['tags.getForObject', props.objectId], { suspense: true }); - const assignTag = useLibraryMutation('tags.assign', { - onSuccess: () => { - submitPlausibleEvent({ event: { type: 'tagAssign' } }); - } - }); - - return ( - <> - {tags.data?.length === 0 && ( -
- -
- )} - {tags.data?.map((tag, index) => { - const active = !!tagsForObject.data?.find((t) => t.id === tag.id); - - return ( - { - e.preventDefault(); - if (props.objectId === null) return; - - assignTag.mutate({ - tag_id: tag.id, - object_id: props.objectId, - unassign: active - }); - }} - > -
-

{tag.name}

- - ); - })} - - ); -}; diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 1a84380de..94dab1d0c 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -11,7 +11,8 @@ import { isObject, useLibraryQuery } from '@sd/client'; -import { Button, Divider, Tooltip, tw } from '@sd/ui'; +import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui'; +import AssignTagMenuItems from '../AssignTagMenuItems'; import FileThumb from '../File/Thumb'; import FavoriteButton from './FavoriteButton'; import Note from './Note'; @@ -123,7 +124,16 @@ export const Inspector = ({ data, context, ...elementProps }: Props) => { ))} - Add Tag + {objectData?.id && ( + Add Tag} + side="left" + sideOffset={5} + alignOffset={-10} + > + + + )}
diff --git a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx index 9342e4cd9..68d582b39 100644 --- a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx @@ -1,10 +1,10 @@ -import { useClientContext, useLibraryMutation, usePlausibleEvent } from '@sd/client'; +import { useLibraryMutation, usePlausibleEvent } from '@sd/client'; import { Dialog, UseDialogProps, useDialog } from '@sd/ui'; import { Input, useZodForm, z } from '@sd/ui/src/forms'; import ColorPicker from '~/components/ColorPicker'; import { usePlatform } from '~/util/Platform'; -export default (props: UseDialogProps) => { +export default (props: UseDialogProps & { assignToObject?: number }) => { const dialog = useDialog(props); const platform = usePlatform(); const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform }); @@ -20,14 +20,23 @@ export default (props: UseDialogProps) => { }); const createTag = useLibraryMutation('tags.create', { - onSuccess: () => { + onSuccess: (tag) => { submitPlausibleEvent({ event: { type: 'tagCreate' } }); + if (props.assignToObject !== undefined) { + assignTag.mutate({ tag_id: tag.id, object_id: props.assignToObject, unassign: false }); + } }, onError: (e) => { console.error('error', e); } }); + const assignTag = useLibraryMutation('tags.assign', { + onSuccess: () => { + submitPlausibleEvent({ event: { type: 'tagAssign' } }); + } + }); + return ( , y = 1) => { + const [isScrolled, setIsScrolled] = useState(false); + + useEffect(() => { + const onScroll = () => { + if (ref.current) { + if (ref.current.scrollTop >= y) setIsScrolled(true); + else setIsScrolled(false); + } + }; + + onScroll(); + ref.current?.addEventListener('scroll', onScroll); + () => ref.current?.removeEventListener('scroll', onScroll); + }, [ref.current, y]); + + return { isScrolled }; +}; diff --git a/packages/ui/src/ContextMenu.tsx b/packages/ui/src/ContextMenu.tsx index d6f4c1dd7..47f362b16 100644 --- a/packages/ui/src/ContextMenu.tsx +++ b/packages/ui/src/ContextMenu.tsx @@ -2,7 +2,7 @@ import * as RadixCM from '@radix-ui/react-context-menu'; import { VariantProps, cva } from 'class-variance-authority'; import clsx from 'clsx'; import { CaretRight, Icon, IconProps } from 'phosphor-react'; -import { PropsWithChildren, Suspense } from 'react'; +import { PropsWithChildren, Suspense, createContext, useContext } from 'react'; interface ContextMenuProps extends RadixCM.MenuContentProps { trigger: React.ReactNode; @@ -10,27 +10,30 @@ interface ContextMenuProps extends RadixCM.MenuContentProps { export const contextMenuClassNames = clsx( 'z-50 max-h-[calc(100vh-20px)] overflow-y-auto', - 'my-2 min-w-[12rem] max-w-[16rem] px-1 py-0.5', + 'my-2 min-w-[12rem] max-w-[16rem] py-0.5', 'bg-menu cool-shadow', 'border-menu-line border', 'cursor-default select-none rounded-md', 'animate-in fade-in' ); +const context = createContext(false); +export const useContextMenu = () => useContext(context); + const Root = ({ trigger, children, className, ...props }: ContextMenuProps) => { return ( {trigger} - {children} + {children} ); }; -export const contextMenuSeparatorClassNames = 'border-b-menu-line my-0.5 border-b'; +export const contextMenuSeparatorClassNames = 'border-b-menu-line mx-1 my-0.5 border-b'; const Separator = (props: { className?: string }) => ( @@ -92,7 +95,7 @@ export interface ContextMenuItemProps extends VariantProps(false); +export const useDropdownMenu = () => useContext(context); + const Root = ({ trigger, children, @@ -43,7 +53,7 @@ const Root = ({ style={{ width }} {...props} > - {children} + {children} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 04103c51e..d2e7e0b08 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,8 +1,8 @@ export { cva, cx } from 'class-variance-authority'; export * from './Button'; export * from './CheckBox'; -export { ContextMenu } from './ContextMenu'; -export { DropdownMenu } from './DropdownMenu'; +export { ContextMenu, useContextMenu } from './ContextMenu'; +export { DropdownMenu, useDropdownMenu } from './DropdownMenu'; export * from './Dialog'; export * as Dropdown from './Dropdown'; export * from './Input';