diff --git a/core/prisma/migrations/20231113140411_tag_date_created/migration.sql b/core/prisma/migrations/20231113140411_tag_date_created/migration.sql new file mode 100644 index 000000000..c4ba0be1c --- /dev/null +++ b/core/prisma/migrations/20231113140411_tag_date_created/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "tag_on_object" ADD COLUMN "date_created" DATETIME; diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 184ed33a9..e124dd315 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -353,6 +353,8 @@ model TagOnObject { object_id Int object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) + date_created DateTime? + @@id([tag_id, object_id]) @@map("tag_on_object") } diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index 59b6f22f1..c682aa4c1 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -55,6 +55,7 @@ pub(crate) fn mount() -> AlphaRouter { .select(tag::select!({ id tag_objects(vec![tag_on_object::object_id::in_vec(object_ids.clone())]): select { + date_created object: select { id } @@ -65,15 +66,7 @@ pub(crate) fn mount() -> AlphaRouter { Ok(tags_with_objects .into_iter() - .map(|tag| { - ( - tag.id, - tag.tag_objects - .into_iter() - .map(|rel| rel.object.id) - .collect::>(), - ) - }) + .map(|tag| (tag.id, tag.tag_objects)) .collect::>()) }, ) @@ -249,7 +242,9 @@ pub(crate) fn mount() -> AlphaRouter { db_creates.push(tag_on_object::CreateUnchecked { tag_id: args.tag_id, object_id: id, - _params: vec![], + _params: vec![tag_on_object::date_created::set(Some( + Utc::now().into(), + ))], }); sync_ops.extend(sync.relation_create(sync_id!(pub_id), [])); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx index daf5f8639..f59e2e1f0 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx @@ -1,9 +1,11 @@ import { Plus } from '@phosphor-icons/react'; +import { useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; -import { useRef } from 'react'; +import { forwardRef, MutableRefObject, RefObject, useMemo, useRef } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; import { ExplorerItem, useLibraryQuery } from '@sd/client'; -import { dialogManager, ModifierKeys } from '@sd/ui'; +import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui'; import CreateDialog, { AssignTagItems, useAssignItemsToTag @@ -13,33 +15,40 @@ import { useOperatingSystem } from '~/hooks'; import { useScrolled } from '~/hooks/useScrolled'; import { keybindForOs } from '~/util/keybinds'; -export default (props: { items: Array> }) => { - const os = useOperatingSystem(); - const keybind = keybindForOs(os); +const EmptyContainer = tw.div`py-1 text-center text-xs text-ink-faint`; + +interface Props { + items: Array>; +} + +function useData({ items }: Props) { const tags = useLibraryQuery(['tags.list'], { suspense: true }); // Map> - const tagsWithObjects = useLibraryQuery([ - 'tags.getWithObjects', - 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 tagsWithObjects = useLibraryQuery( + [ + 'tags.getWithObjects', + 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) + ], + { suspense: true } + ); - const parentRef = useRef(null); - const rowVirtualizer = useVirtualizer({ - count: tags.data?.length || 0, - getScrollElement: () => parentRef.current, - estimateSize: () => 30, - paddingStart: 2 - }); + return { tags, tagsWithObjects }; +} - const { isScrolled } = useScrolled(parentRef, 10); +export default (props: Props) => { + const ref = useRef(null); + const { isScrolled } = useScrolled(ref, 10); - const assignItemsToTag = useAssignItemsToTag(); + const os = useOperatingSystem(); + const keybind = keybindForOs(os); + + const queryClient = useQueryClient(); return ( <> @@ -54,7 +63,85 @@ export default (props: { items: Array - {tags.data && tags.data.length > 0 ? ( + queryClient.invalidateQueries()} + fallbackRender={(props) => ( + + Failed to load tags + + + )} + > + + + + ); +}; + +const Tags = ({ items, parentRef }: Props & { parentRef: RefObject }) => { + const { tags, tagsWithObjects } = useData({ items }); + + // tags are sorted by assignment, and assigned tags are sorted by most recently assigned + const sortedTags = useMemo(() => { + if (!tags.data) return []; + + const assigned = []; + const unassigned = []; + + for (const tag of tags.data) { + if (tagsWithObjects.data?.[tag.id] === undefined) unassigned.push(tag); + else assigned.push(tag); + } + + if (tagsWithObjects.data) { + assigned.sort((a, b) => { + const aObjs = tagsWithObjects.data[a.id], + bObjs = tagsWithObjects.data[b.id]; + + function getMaxDate(data: typeof aObjs) { + if (!data) return null; + let max = null; + + for (const { date_created } of data) { + if (!date_created) continue; + + const date = new Date(date_created); + + if (!max) max = date; + else if (date > max) max = date; + } + + return max; + } + + const aMaxDate = getMaxDate(aObjs), + bMaxDate = getMaxDate(bObjs); + + if (!aMaxDate || !bMaxDate) { + if (aMaxDate && !bMaxDate) return 1; + else if (!aMaxDate && bMaxDate) return -1; + else return 0; + } else { + return Number(bMaxDate) - Number(aMaxDate); + } + }); + } + + return [...assigned, ...unassigned]; + }, [tags.data, tagsWithObjects.data]); + + const rowVirtualizer = useVirtualizer({ + count: sortedTags.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 30, + paddingStart: 2 + }); + + const assignItemsToTag = useAssignItemsToTag(); + + return ( + <> + {sortedTags.length > 0 ? (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { - const tag = tags.data[virtualRow.index]; + const tag = sortedTags[virtualRow.index]; if (!tag) return null; - const objectsWithTag = new Set(tagsWithObjects.data?.[tag?.id]); + const objectsWithTag = new Set( + tagsWithObjects.data?.[tag?.id]?.map((d) => d.object.id) + ); // only unassign if all objects have tag // this is the same functionality as finder - const unassign = props.items.every((item) => { + const unassign = items.every((item) => { if (item.type === 'Object') { return objectsWithTag.has(item.item.id); } else { @@ -100,7 +189,7 @@ export default (props: { items: Array { + items.flatMap((item) => { if ( item.type === 'Object' || item.type === 'Path' @@ -111,22 +200,16 @@ export default (props: { items: Array( - (item) => { - if (item.type === 'Object') { - if ( - !objectsWithTag.has( - item.item.id - ) - ) - return [item]; - } else if (item.type === 'Path') { + items.flatMap((item) => { + if (item.type === 'Object') { + if (!objectsWithTag.has(item.item.id)) return [item]; - } - - return []; + } else if (item.type === 'Path') { + return [item]; } - ), + + return []; + }), unassign ); @@ -152,9 +235,7 @@ export default (props: { items: Array
) : ( -
- {tags.data ? 'No tags' : 'Failed to load tags'} -
+ No tags )} ); diff --git a/interface/app/$libraryId/settings/client/backups.tsx b/interface/app/$libraryId/settings/client/backups.tsx index fe92fc26a..a711c7dca 100644 --- a/interface/app/$libraryId/settings/client/backups.tsx +++ b/interface/app/$libraryId/settings/client/backups.tsx @@ -17,8 +17,6 @@ export const Component = () => { const doRestore = useBridgeMutation('backups.restore'); const doDelete = useBridgeMutation('backups.delete'); - console.log(doRestore.isLoading); - return ( <> , result: CRDTOperation[] } | { key: "tags.get", input: LibraryArgs, result: Tag | null } | { key: "tags.getForObject", input: LibraryArgs, result: Tag[] } | - { key: "tags.getWithObjects", input: LibraryArgs, result: { [key: number]: number[] } } | + { key: "tags.getWithObjects", input: LibraryArgs, result: { [key: number]: { date_created: string | null; object: { id: number } }[] } } | { key: "tags.list", input: LibraryArgs, result: Tag[] } | { key: "volumes.list", input: never, result: Volume[] }, mutations: diff --git a/packages/ui/src/DropdownMenu.tsx b/packages/ui/src/DropdownMenu.tsx index a94277b4e..a5080a8af 100644 --- a/packages/ui/src/DropdownMenu.tsx +++ b/packages/ui/src/DropdownMenu.tsx @@ -69,16 +69,18 @@ const Root = (props: PropsWithChildren) => { {trigger} - - - {children} - - + + + + {children} + + + );