[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 <brendonovich@outlook.com>
This commit is contained in:
nikec 2023-03-30 05:11:21 +02:00 committed by GitHub
parent 04542a367c
commit 7d996a10cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 187 additions and 72 deletions

View file

@ -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<HTMLDivElement>(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 (
<>
<Menu.Item
label="New tag"
icon={Plus}
iconProps={{ size: 15 }}
keybind="⌘N"
onClick={() => {
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={props.objectId} />);
}}
/>
<Menu.Separator className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')} />
{tags.data && tags.data.length > 0 ? (
<div
ref={parentRef}
style={{
maxHeight: `400px`,
height: `100%`,
width: `100%`,
overflow: 'auto'
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
>
{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 (
<Menu.Item
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
onClick={(e) => {
e.preventDefault();
assignTag.mutate({
tag_id: tag.id,
object_id: props.objectId,
unassign: active
});
}}
>
<div
className="mr-0.5 h-[15px] w-[15px] shrink-0 rounded-full border"
style={{
backgroundColor: active && tag.color ? tag.color : 'transparent',
borderColor: tag.color || '#efefef'
}}
/>
<span className="truncate">{tag.name}</span>
</Menu.Item>
);
})}
</div>
</div>
) : (
<div className="text-ink-faint py-1 text-center text-xs">
{tags.data ? 'No tags' : 'Failed to load tags'}
</div>
)}
</>
);
};

View file

@ -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) => {
<ContextMenu.Separator />
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={objectData?.id || 0} />
</ContextMenu.SubMenu>
{objectData && (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={objectData.id} />
</ContextMenu.SubMenu>
)}
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<ContextMenu.Item
@ -247,55 +249,3 @@ export default ({ data, ...props }: Props) => {
</div>
);
};
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 && (
<div className="m-1 pb-10">
<Input autoFocus defaultValue="New tag" />
</div>
)}
{tags.data?.map((tag, index) => {
const active = !!tagsForObject.data?.find((t) => t.id === tag.id);
return (
<ContextMenu.Item
key={tag.id}
keybind={`${index + 1}`}
onClick={(e) => {
e.preventDefault();
if (props.objectId === null) return;
assignTag.mutate({
tag_id: tag.id,
object_id: props.objectId,
unassign: active
});
}}
>
<div
className="mr-0.5 block h-[15px] w-[15px] rounded-full border"
style={{
backgroundColor: active ? tag.color || '#efefef' : 'transparent' || '#efefef',
borderColor: tag.color || '#efefef'
}}
/>
<p>{tag.name}</p>
</ContextMenu.Item>
);
})}
</>
);
};

View file

@ -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) => {
</InfoPill>
</Tooltip>
))}
<PlaceholderPill>Add Tag</PlaceholderPill>
{objectData?.id && (
<DropdownMenu.Root
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
side="left"
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems objectId={objectData.id} />
</DropdownMenu.Root>
)}
</div>
</MetaContainer>
<Divider />

View file

@ -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 (
<Dialog
{...{ dialog, form }}

View file

@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
export const useScrolled = (ref: React.RefObject<HTMLDivElement>, 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 };
};

View file

@ -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<boolean>(false);
export const useContextMenu = () => useContext(context);
const Root = ({ trigger, children, className, ...props }: ContextMenuProps) => {
return (
<RadixCM.Root>
<RadixCM.Trigger asChild>{trigger}</RadixCM.Trigger>
<RadixCM.Portal>
<RadixCM.Content className={clsx(contextMenuClassNames, className)} {...props}>
{children}
<context.Provider value={true}>{children}</context.Provider>
</RadixCM.Content>
</RadixCM.Portal>
</RadixCM.Root>
);
};
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 }) => (
<RadixCM.Separator className={clsx(contextMenuSeparatorClassNames, props.className)} />
@ -92,7 +95,7 @@ export interface ContextMenuItemProps extends VariantProps<typeof contextMenuIte
keybind?: string;
}
export const contextMenuItemClassNames = 'group py-0.5 outline-none';
export const contextMenuItemClassNames = 'group py-0.5 outline-none px-1';
const Item = ({
icon,

View file

@ -1,6 +1,13 @@
import * as RadixDM from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import React, { PropsWithChildren, Suspense, useCallback, useRef, useState } from 'react';
import React, {
PropsWithChildren,
Suspense,
useCallback,
useContext,
useRef,
useState
} from 'react';
import { Link } from 'react-router-dom';
import {
ContextMenuDivItem,
@ -16,6 +23,9 @@ interface DropdownMenuProps extends RadixDM.MenuContentProps {
alignToTrigger?: boolean;
}
const context = React.createContext<boolean>(false);
export const useDropdownMenu = () => useContext(context);
const Root = ({
trigger,
children,
@ -43,7 +53,7 @@ const Root = ({
style={{ width }}
{...props}
>
{children}
<context.Provider value={true}>{children}</context.Provider>
</RadixDM.Content>
</RadixDM.Portal>
</RadixDM.Root>

View file

@ -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';