mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-10-24 09:58:35 -04:00
[ENG-443] Allow folders to be tagged (#1712)
* allow folders to be tagged * remove bruh * custom useAssignItems hook * fix types
This commit is contained in:
1 parent
282e35c294
commit
c91ccff37d
10 files changed
+305
-149
No files matched your search
+66
-54
@@ -2,39 +2,33 @@ import { Plus } from '@phosphor-icons/react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
Object,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
usePlausibleEvent,
|
||||
useRspcLibraryContext
|
||||
} from '@sd/client';
|
||||
import { ExplorerItem, useLibraryQuery } from '@sd/client';
|
||||
import { dialogManager, ModifierKeys } from '@sd/ui';
|
||||
import CreateDialog from '~/app/$libraryId/settings/library/tags/CreateDialog';
|
||||
import CreateDialog, {
|
||||
AssignTagItems,
|
||||
useAssignItemsToTag
|
||||
} from '~/app/$libraryId/settings/library/tags/CreateDialog';
|
||||
import { Menu } from '~/components/Menu';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { useScrolled } from '~/hooks/useScrolled';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
|
||||
import { Menu } from './Menu';
|
||||
|
||||
export default (props: { objects: Object[] }) => {
|
||||
export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | 'Path' }>> }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
const rspc = useRspcLibraryContext();
|
||||
const tags = useLibraryQuery(['tags.list'], { suspense: true });
|
||||
|
||||
// Map<tag::id, Vec<object::id>>
|
||||
const tagsWithObjects = useLibraryQuery([
|
||||
'tags.getWithObjects',
|
||||
props.objects.map(({ id }) => id)
|
||||
props.items
|
||||
.map((item) => {
|
||||
if (item.type === 'Path') return item.item.object?.id;
|
||||
else if (item.type === 'Object') return item.item.id;
|
||||
})
|
||||
.filter((item): item is number => item !== undefined)
|
||||
]);
|
||||
|
||||
const assignTag = useLibraryMutation('tags.assign', {
|
||||
onSuccess: () => {
|
||||
submitPlausibleEvent({ event: { type: 'tagAssign' } });
|
||||
}
|
||||
});
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tags.data?.length || 0,
|
||||
@@ -45,6 +39,8 @@ export default (props: { objects: Object[] }) => {
|
||||
|
||||
const { isScrolled } = useScrolled(parentRef, 10);
|
||||
|
||||
const assignItemsToTag = useAssignItemsToTag();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Item
|
||||
@@ -54,71 +50,87 @@ export default (props: { objects: Object[] }) => {
|
||||
iconProps={{ size: 15 }}
|
||||
keybind={keybind([ModifierKeys.Control], ['N'])}
|
||||
onClick={() => {
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} objects={props.objects} />);
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} items={props.items} />);
|
||||
}}
|
||||
/>
|
||||
<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'
|
||||
}}
|
||||
className="h-full w-full overflow-auto"
|
||||
style={{ maxHeight: `400px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const tag = tags.data[virtualRow.index];
|
||||
if (!tag) return null;
|
||||
|
||||
const objectsWithTag = tagsWithObjects.data?.[tag?.id];
|
||||
const objectsWithTag = new Set(tagsWithObjects.data?.[tag?.id]);
|
||||
|
||||
// only unassign if all objects have tag
|
||||
// this is the same functionality as finder
|
||||
const unassign = objectsWithTag?.length === props.objects.length;
|
||||
const unassign = props.items.every((item) => {
|
||||
if (item.type === 'Object') {
|
||||
return objectsWithTag.has(item.item.id);
|
||||
} else {
|
||||
const { object } = item.item;
|
||||
|
||||
if (!object) return false;
|
||||
return objectsWithTag.has(object.id);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: UI to differentiate tag assigning when some objects have tag when no objects have tag - ENG-965
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={virtualRow.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
await assignTag.mutateAsync({
|
||||
unassign,
|
||||
tag_id: tag.id,
|
||||
object_ids: unassign
|
||||
await assignItemsToTag(
|
||||
tag.id,
|
||||
unassign
|
||||
? // use objects that already have tag
|
||||
objectsWithTag
|
||||
props.items.flatMap((item) => {
|
||||
if (
|
||||
item.type === 'Object' ||
|
||||
item.type === 'Path'
|
||||
) {
|
||||
return [item];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
: // use objects that don't have tag
|
||||
props.objects
|
||||
.filter(
|
||||
(o) =>
|
||||
!objectsWithTag?.some(
|
||||
(ot) => ot === o.id
|
||||
props.items.flatMap<AssignTagItems[number]>(
|
||||
(item) => {
|
||||
if (item.type === 'Object') {
|
||||
if (
|
||||
!objectsWithTag.has(
|
||||
item.item.id
|
||||
)
|
||||
)
|
||||
)
|
||||
.map((o) => o.id)
|
||||
});
|
||||
if (unassign)
|
||||
rspc.queryClient.invalidateQueries(['search.objects']);
|
||||
return [item];
|
||||
} else if (item.type === 'Path') {
|
||||
return [item];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
),
|
||||
unassign
|
||||
);
|
||||
|
||||
tagsWithObjects.refetch();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -126,7 +138,7 @@ export default (props: { objects: Object[] }) => {
|
||||
style={{
|
||||
backgroundColor:
|
||||
objectsWithTag &&
|
||||
objectsWithTag.length > 0 &&
|
||||
objectsWithTag.size > 0 &&
|
||||
tag.color
|
||||
? tag.color
|
||||
: 'transparent',
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ArrowBendUpRight, TagSimple } from '@phosphor-icons/react';
|
||||
import { useMemo } from 'react';
|
||||
import { ObjectKind, useLibraryMutation, type ObjectKindEnum } from '@sd/client';
|
||||
import { ExplorerItem, ObjectKind, useLibraryMutation, type ObjectKindEnum } from '@sd/client';
|
||||
import { ContextMenu, toast } from '@sd/ui';
|
||||
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
|
||||
import { Menu } from '~/components/Menu';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import AssignTagMenuItems from '../AssignTagMenuItems';
|
||||
import { ConditionalItem } from '../ConditionalItem';
|
||||
import { useContextMenuContext } from '../context';
|
||||
|
||||
@@ -43,14 +43,24 @@ export const RemoveFromRecents = new ConditionalItem({
|
||||
|
||||
export const AssignTag = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedObjects } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedObjects)) return null;
|
||||
const { selectedItems } = useContextMenuContext();
|
||||
|
||||
return { selectedObjects };
|
||||
const items = selectedItems
|
||||
.map((item) => {
|
||||
if (item.type === 'Object' || item.type === 'Path') return item;
|
||||
})
|
||||
.filter(
|
||||
(item): item is Extract<ExplorerItem, { type: 'Object' | 'Path' }> =>
|
||||
item !== undefined
|
||||
);
|
||||
|
||||
if (!isNonEmpty(items)) return null;
|
||||
|
||||
return { items };
|
||||
},
|
||||
Component: ({ selectedObjects }) => (
|
||||
Component: ({ items }) => (
|
||||
<Menu.SubMenu label="Assign tag" icon={TagSimple}>
|
||||
<AssignTagMenuItems objects={selectedObjects} />
|
||||
<AssignTagMenuItems items={items} />
|
||||
</Menu.SubMenu>
|
||||
)
|
||||
});
|
||||
|
||||
@@ -164,7 +164,7 @@ export const FileThumb = memo((props: ThumbProps) => {
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex shrink-0 items-center justify-center',
|
||||
!loaded && 'invisible',
|
||||
// !loaded && 'invisible',
|
||||
!props.size && 'h-full w-full',
|
||||
props.cover && 'overflow-hidden',
|
||||
props.className
|
||||
|
||||
@@ -44,12 +44,12 @@ import {
|
||||
} from '@sd/client';
|
||||
import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui';
|
||||
import { LibraryIdParamsSchema } from '~/app/route-schemas';
|
||||
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
|
||||
import { useIsDark, useZodRouteParams } from '~/hooks';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import { Folder } from '../../../../components';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import AssignTagMenuItems from '../ContextMenu/AssignTagMenuItems';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
@@ -192,7 +192,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||
break;
|
||||
}
|
||||
case 'SpacedropPeer': {
|
||||
objectData = item.item as unknown as Object;
|
||||
// objectData = item.item as unknown as Object;
|
||||
// filePathData = item.item.file_paths[0] ?? null;
|
||||
break;
|
||||
}
|
||||
@@ -347,16 +347,17 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{objectData && (
|
||||
<DropdownMenu.Root
|
||||
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems objects={[objectData]} />
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
{item.type === 'Object' ||
|
||||
(item.type === 'Path' && (
|
||||
<DropdownMenu.Root
|
||||
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems items={[item]} />
|
||||
</DropdownMenu.Root>
|
||||
))}
|
||||
</MetaContainer>
|
||||
|
||||
{!isDir && objectData && (
|
||||
@@ -520,7 +521,12 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems objects={selectedObjects} />
|
||||
<AssignTagMenuItems
|
||||
items={items.flatMap((item) => {
|
||||
if (item.type === 'Object' || item.type === 'Path') return [item];
|
||||
else return [];
|
||||
})}
|
||||
/>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</MetaContainer>
|
||||
|
||||
@@ -226,7 +226,9 @@ const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => {
|
||||
)
|
||||
return;
|
||||
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} objects={objects} />);
|
||||
dialogManager.create((dp) => (
|
||||
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
|
||||
));
|
||||
},
|
||||
[os, explorer.selectedItems]
|
||||
);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Object, useLibraryMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import {
|
||||
FilePath,
|
||||
libraryClient,
|
||||
Object,
|
||||
Target,
|
||||
useLibraryMutation,
|
||||
usePlausibleEvent,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Dialog, InputField, useDialog, UseDialogProps, z } from '@sd/ui';
|
||||
import { ColorPicker } from '~/components';
|
||||
|
||||
@@ -7,7 +15,41 @@ const schema = z.object({
|
||||
color: z.string()
|
||||
});
|
||||
|
||||
export default (props: UseDialogProps & { objects?: Object[] }) => {
|
||||
export type AssignTagItems = Array<
|
||||
{ type: 'Object'; item: Object } | { type: 'Path'; item: FilePath }
|
||||
>;
|
||||
|
||||
export function useAssignItemsToTag() {
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const mutation = useLibraryMutation(['tags.assign'], {
|
||||
onSuccess: () => {
|
||||
submitPlausibleEvent({ event: { type: 'tagAssign' } });
|
||||
}
|
||||
});
|
||||
|
||||
return (tagId: number, items: AssignTagItems, unassign: boolean) => {
|
||||
const targets = items.map<Target>((item) => {
|
||||
if (item.type === 'Object') {
|
||||
return { Object: item.item.id };
|
||||
} else {
|
||||
return { FilePath: item.item.id };
|
||||
}
|
||||
});
|
||||
|
||||
return mutation.mutateAsync({
|
||||
tag_id: tagId,
|
||||
targets,
|
||||
unassign
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default (
|
||||
props: UseDialogProps & {
|
||||
items?: AssignTagItems;
|
||||
}
|
||||
) => {
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const form = useZodForm({
|
||||
@@ -17,7 +59,8 @@ export default (props: UseDialogProps & { objects?: Object[] }) => {
|
||||
});
|
||||
|
||||
const createTag = useLibraryMutation('tags.create');
|
||||
const assignTag = useLibraryMutation('tags.assign');
|
||||
|
||||
const assignItemsToTag = useAssignItemsToTag();
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
@@ -25,13 +68,7 @@ export default (props: UseDialogProps & { objects?: Object[] }) => {
|
||||
|
||||
submitPlausibleEvent({ event: { type: 'tagCreate' } });
|
||||
|
||||
if (props.objects !== undefined) {
|
||||
await assignTag.mutateAsync({
|
||||
tag_id: tag.id,
|
||||
object_ids: props.objects.map((o) => o.id),
|
||||
unassign: false
|
||||
});
|
||||
}
|
||||
if (props.items !== undefined) await assignItemsToTag(tag.id, props.items, false);
|
||||
} catch (e) {
|
||||
console.error('error', e);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user