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:
103
web/src/routes/review/+page.svelte
Normal file
103
web/src/routes/review/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user