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:
68
web/src/lib/services/photoActions.ts
Normal file
68
web/src/lib/services/photoActions.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user