mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 13:23:28 +00:00
[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:
parent
04542a367c
commit
7d996a10cc
113
interface/app/$libraryId/Explorer/AssignTagMenuItems.tsx
Normal file
113
interface/app/$libraryId/Explorer/AssignTagMenuItems.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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 }}
|
||||
|
|
20
interface/hooks/useScrolled.tsx
Normal file
20
interface/hooks/useScrolled.tsx
Normal 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 };
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue