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 /review — PhotoPrism's quality-flagged photo queue, one tab per
cause (mirrors /tags' pill row). Only causes with non-zero photos cause. Tiles, selection, keyboard nav, and bulk actions all come
get a tab. Active tab is URL-driven via `?tab=<cause_key>` so from the shared timeline machinery (PhotoGrid, gridKeyNav,
refresh / share / back land on the same panel. BulkActionBar, BulkMetadataSidebar) so muscle memory carries
across routes.
Clicking a tile focuses that photo in the global selection store; The route flips `filters.section = 'review'` while it's mounted —
the right-hand aside mounts `RightSidebar` with `showRelated=true` that's what swings the shared action surface into review semantics
to surface same-folder / camera / year strips. (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"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -18,22 +21,36 @@
type ReviewGroup type ReviewGroup
} from '$lib/services/adapters/review'; } from '$lib/services/adapters/review';
import { isAuthenticated } from '$lib/stores/session.svelte'; 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 { import {
setRightSidebarWidth,
setThumbnailSize, setThumbnailSize,
THUMBNAIL_SIZE_LABELS, THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS, THUMBNAIL_SIZE_PRESETS,
view view
} from '$lib/stores/view.svelte'; } from '$lib/stores/view.svelte';
import { getPhoto } from '$lib/services/photoprism'; 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 type { PpPhoto } from '$lib/types/photoprism';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; 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 RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
function onSelect(photo: PpPhoto) { // Stash the section that was active when the user arrived; restore
setFocused(photo.UID); // 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[]>(() => ({ const reviewQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'], queryKey: ['review-groups'],
@@ -49,31 +66,35 @@
})); }));
const groups = $derived(reviewQuery.data ?? []); const groups = $derived(reviewQuery.data ?? []);
// Only causes with hits get a tab — empty buckets are filtered out const tabs = $derived(
// in the adapter already, but $derived re-runs whenever data changes. 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')); 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(() => { const activeTab: CauseKey | null = $derived.by(() => {
if (tabs.length === 0) return null; if (tabs.length === 0) return null;
const want = tabs.find((t) => t.id === requestedTab); const want = tabs.find((t) => t.id === requestedTab);
return (want ?? tabs[0]).id; 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) { function setTab(id: CauseKey) {
const params = new URLSearchParams(); 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); if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
void goto(`/review${params.size ? '?' + params : ''}`, { void goto(`/review${params.size ? '?' + params : ''}`, {
keepFocus: true, keepFocus: true,
noScroll: true noScroll: true
}); });
} }
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
</script> </script>
<Toolbar> <Toolbar>
@@ -81,8 +102,6 @@
Review Review
</span> </span>
{#if tabs.length > 0} {#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"> <div class="flex items-center gap-1">
{#each tabs as t (t.id)} {#each tabs as t (t.id)}
<button <button
@@ -122,7 +141,8 @@
</Toolbar> </Toolbar>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6"> <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} {#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p> <p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error} {:else if reviewQuery.error}
@@ -142,23 +162,38 @@
</div> </div>
{:else if activeGroup} {:else if activeGroup}
{#key activeGroup.cause} {#key activeGroup.cause}
<CauseGroupCard group={activeGroup} autoFocus {onSelect} /> <CauseGroupCard group={activeGroup} />
{/key} {/key}
{/if} {/if}
</main> </main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed && selection.focused} {#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
<aside <aside
class="relative h-full shrink-0 border-l border-border bg-card/30" class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;" style="width: {view.rightSidebarWidth}px;"
> >
<div class="h-full overflow-y-auto"> <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 /> <RightSidebar photo={focusedPhotoQuery.data} showRelated />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p> <p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
{/if} {/if}
</div> </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> </aside>
{/if} {/if}
</div> </div>