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

View File

@@ -1,12 +1,15 @@
<!--
/review — PhotoPrism's quality-flagged photo queue, one tab per
cause (mirrors /tags' pill row). Only causes with non-zero photos
get a tab. Active tab is URL-driven via `?tab=<cause_key>` so
refresh / share / back land on the same panel.
cause. Tiles, selection, keyboard nav, and bulk actions all come
from the shared timeline machinery (PhotoGrid, gridKeyNav,
BulkActionBar, BulkMetadataSidebar) so muscle memory carries
across routes.
Clicking a tile focuses that photo in the global selection store;
the right-hand aside mounts `RightSidebar` with `showRelated=true`
to surface same-folder / camera / year strips.
The route flips `filters.section = 'review'` while it's mounted —
that's what swings the shared action surface into review semantics
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
approve). The previous section is restored on unmount so going
back to `/` lands on whatever the user had before.
-->
<script lang="ts">
import { goto } from '$app/navigation';
@@ -18,22 +21,36 @@
type ReviewGroup
} from '$lib/services/adapters/review';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { selection, setFocused } from '$lib/stores/selection.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import {
setRightSidebarWidth,
setThumbnailSize,
THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS,
view
} from '$lib/stores/view.svelte';
import { getPhoto } from '$lib/services/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import type { PpPhoto } from '$lib/types/photoprism';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
function onSelect(photo: PpPhoto) {
setFocused(photo.UID);
}
// Stash the section that was active when the user arrived; restore
// on unmount so navigating away doesn't leak `section=review` to
// the timeline (which would silently re-filter it).
const prevSection: Section = filters.section;
$effect(() => {
setSection('review');
return () => {
setSection(prevSection);
clearSelection();
};
});
const reviewQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'],
@@ -49,31 +66,35 @@
}));
const groups = $derived(reviewQuery.data ?? []);
// Only causes with hits get a tab — empty buckets are filtered out
// in the adapter already, but $derived re-runs whenever data changes.
const tabs = $derived(groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.length })));
const tabs = $derived(
groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.length }))
);
const requestedTab = $derived(page.url.searchParams.get('tab'));
// Resolve the active tab: honour the URL when it points at a tab
// that still has hits; otherwise fall back to the first available
// tab so refresh after clearing a category doesn't strand the user.
const activeTab: CauseKey | null = $derived.by(() => {
if (tabs.length === 0) return null;
const want = tabs.find((t) => t.id === requestedTab);
return (want ?? tabs[0]).id;
});
// Selection is global — without this effect, a user who multi-
// selected in `low_resolution` then switched to `stripped_exif`
// would carry the previous tab's UIDs into the new tab's
// BulkActionBar verbs and accidentally act on the wrong photos.
$effect(() => {
void activeTab;
clearSelection();
});
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
function setTab(id: CauseKey) {
const params = new URLSearchParams();
// First tab is the implicit default; keep the URL clean for it.
if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
void goto(`/review${params.size ? '?' + params : ''}`, {
keepFocus: true,
noScroll: true
});
}
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
</script>
<Toolbar>
@@ -81,8 +102,6 @@
Review
</span>
{#if tabs.length > 0}
<!-- Pill row of cause tabs. Same chrome as /tags so the active
state reads consistently across the app. -->
<div class="flex items-center gap-1">
{#each tabs as t (t.id)}
<button
@@ -122,43 +141,59 @@
</Toolbar>
<div class="flex min-h-0 flex-1">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6">
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here.
<div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} autoFocus {onSelect} />
{/key}
{/if}
</main>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here.
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed && selection.focused}
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
<aside
class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;"
>
<div class="h-full overflow-y-auto">
{#if focusedPhotoQuery.data}
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
{/if}
</div>
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
></div>
</aside>
{/if}
</div>