Review view: cause-grouped UI with actions + suggestions

Builds a dedicated /review route that mirrors /duplicates' chrome:
- /review/+page.svelte mounts Toolbar + ReviewView + RightSidebar
- CauseGroupCard.svelte renders one card per cause with Approve all
  and Archive all bulk actions plus a per-cause suggestion line
- CauseBadges.svelte shows every matching cause as chips on each tile
- services/adapters/review.ts fetches review:true and groups photos
  by primary cause; current taxonomy is low_resolution >
  stripped_exif > implausible_year > non_image_type > quality_other
  (low_resolution ranks first because it's the most actionable signal)

Sidebar gains an opt-in showRelated prop that adds three
RelatedStrip panels (same folder / camera / year) for the
'decide these together' workflow.

LeftSidebar's Review entry switches from a section filter to a
route link so /review picks up the click.

PpPhoto gains the missing Resolution field PhotoPrism actually
returns on list responses.
This commit is contained in:
Claudio
2026-05-18 09:03:30 +00:00
parent 9d955d6b94
commit ca9f6e6bd2
9 changed files with 863 additions and 2 deletions

View File

@@ -0,0 +1,103 @@
<!--
/review — PhotoPrism's quality-flagged photo queue rendered as
cause-grouped cards (mirrors /duplicates). A click on any tile
focuses that photo in the existing selection store; the right-hand
aside picks up the change and mounts RightSidebar in its existing
"single-photo metadata" mode, extended with related-photo strips
via `showRelated`.
-->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { listReviewGroups } from '$lib/services/adapters/review';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { selection, setFocused } from '$lib/stores/selection.svelte';
import {
setThumbnailSize,
THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS,
view
} from '$lib/stores/view.svelte';
import { getPhoto } from '$lib/services/photoprism';
import type { PpPhoto } from '$lib/types/photoprism';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import ReviewView from '$lib/components/review/ReviewView.svelte';
// Reuse the global selection store so the sidebar wiring matches the
// timeline — focus a photo here, the same `RightSidebar` consumer
// can render it without a parallel store.
function onSelect(photo: PpPhoto) {
setFocused(photo.UID);
}
const reviewQuery = createQuery(() => ({
queryKey: ['review-groups'],
queryFn: listReviewGroups,
enabled: isAuthenticated(),
// Keep the queue fresh on tab focus; mutations invalidate
// directly so this is mostly insurance against external pushes
// (re-index, etc).
staleTime: 30_000
}));
// Detail fetch for the focused photo — same pattern as the timeline,
// keyed on the UID so swap is automatic.
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
enabled: isAuthenticated() && Boolean(selection.focused)
}));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review
</span>
{#snippet trailing()}
<div
class="flex items-center overflow-hidden rounded border border-border"
role="group"
aria-label="Thumbnail size"
>
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
<button
type="button"
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
class:bg-accent={view.thumbnailSize === size}
class:text-foreground={view.thumbnailSize === size}
class:text-muted-foreground={view.thumbnailSize !== size}
onclick={() => setThumbnailSize(size)}
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
>
{THUMBNAIL_SIZE_LABELS[i]}
</button>
{/each}
</div>
{/snippet}
</Toolbar>
<div class="flex min-h-0 flex-1">
<main class="min-h-0 flex-1 overflow-y-auto">
<ReviewView
groups={reviewQuery.data ?? []}
pending={reviewQuery.isPending}
error={reviewQuery.error}
{onSelect}
/>
</main>
{#if !view.rightSidebarCollapsed && selection.focused}
<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}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
{/if}
</div>
</aside>
{/if}
</div>