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.
This commit is contained in:
Claudio
2026-05-18 18:48:48 +00:00
parent 70de4b65ec
commit 8ac406ac1f
2 changed files with 150 additions and 47 deletions

View File

@@ -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<void> {
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<void> {
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}`);
}