From 8ac406ac1f762d72f5ae667cefea139de78ad944 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 18 May 2026 18:48:48 +0000 Subject: [PATCH] Review view: reuse timeline action surface, simpler card UX The /review cards now share the timeline's PhotoGrid, gridKeyNav, BulkActionBar, and global selection store instead of carrying parallel implementations. The card is reduced to a header (title + count + Dismiss / Archive all), an advisory caption, and a PhotoGrid; keyboard nav, hover affordances, multi-select, and bulk verbs come from the shared machinery. - New web/src/lib/services/photoActions.ts holds the canonical dismissPhotos / archivePhotos helpers (toast wording, focus advance, undo push, ['photos'] + ['review-groups'] cache invalidation). BulkActionBar.onApprove / onArchive and gridKeyNav.approveCullTargets / toggleArchive('archive') route through it. CauseGroupCard's Dismiss / Archive-all buttons call the same helpers - one code path from any surface. - Approve verb renamed to "Dismiss" across BulkActionBar, gridKeyNav toasts ("Kept N" -> "Dismissed N"), and the new review card. The BulkActionBar Clear/Dismiss clear button is just "Clear" now so the verb only means the action. - /review sets filters.section='review' on mount and restores on unmount, which is what swings the shared action surface into review semantics; an effect clears the selection on tab change so a previously-selected photo from another cause can't be hit by a new tab's bulk verb. - The route mounts BulkActionBar at the bottom and swaps the right aside to BulkMetadataSidebar when selection.ids.size >= 2 - same as the timeline; gives the user a one-shot "apply this Date / Caption / Keyword to all selected" affordance for EXIF-stripped batches. - CauseGroupCard drops its bespoke keyboard handler, ResizeObserver, focusedIdx state, per-tile hover Approve/Archive buttons, confirm() dialogs, and toast.loading worker loop. The unused CauseBadges component is removed. --- web/src/lib/services/photoActions.ts | 68 ++++++++++++++ web/src/routes/review/+page.svelte | 129 +++++++++++++++++---------- 2 files changed, 150 insertions(+), 47 deletions(-) create mode 100644 web/src/lib/services/photoActions.ts diff --git a/web/src/lib/services/photoActions.ts b/web/src/lib/services/photoActions.ts new file mode 100644 index 0000000..e377365 --- /dev/null +++ b/web/src/lib/services/photoActions.ts @@ -0,0 +1,68 @@ +/** + * Shared action helpers for the photo-app's review / archive flows. + * + * Three callers reach for these: + * 1. `gridKeyNav` — the document-level keyboard action (S / X) + * 2. `BulkActionBar` — the footer button row that appears on selection + * 3. `/review` CauseGroupCard — per-cause "Dismiss all" / "Archive all" + * + * Centralising the toast text, undo wiring, focus advance, and cache + * invalidation here keeps the three surfaces in lockstep — change the + * toast wording in one place and everywhere shows the same verb. + */ + +import { toast } from 'svelte-sonner'; +import { batchEdit } from './batch'; +import { invalidatePhotos } from './bulk'; +import { approvePhoto, batchArchive, batchRestore } from './photoprism'; +import { queryClient } from '$lib/queryClient'; +import { clearSelection, focusAfter } from '$lib/stores/selection.svelte'; +import { push as pushUndo } from '$lib/stores/undo.svelte'; + +/** + * Dismiss photos out of the review queue by bumping their quality + * score above PhotoPrism's threshold. One-way: there's no + * `/unapprove` endpoint, so we do NOT push an undo entry — a re-keyed + * action would just be a no-op on already-approved photos. + */ +export async function dismissPhotos(uids: string[]): Promise { + if (uids.length === 0) return; + const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id)); + // Advance focus past the dismissed set before the timeline refetches + // so the cursor doesn't snap back to photo[0]; clear the now-stale + // selection ring for the same reason. + focusAfter(uids); + clearSelection(); + invalidatePhotos(uids); + void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); + if (errors.length) { + toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, { + description: errors[0].message + }); + return; + } + toast.success(`Dismissed ${uids.length}`); +} + +/** + * Archive photos. Reversible via the undo stack (Restore on ⌘Z). + */ +export async function archivePhotos(uids: string[]): Promise { + if (uids.length === 0) return; + try { + await batchArchive(uids); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Archive failed'); + return; + } + pushUndo(`Archived ${uids.length}`, async () => { + await batchRestore(uids); + invalidatePhotos(uids); + void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); + }); + focusAfter(uids); + clearSelection(); + invalidatePhotos(uids); + void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); + toast.success(`Archived ${uids.length}`); +} diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index ab4a12f..9d7926e 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -1,12 +1,15 @@ @@ -81,8 +102,6 @@ Review {#if tabs.length > 0} -
{#each tabs as t (t.id)}