Review view: tabs per cause (mirrors /tags pill row)

Each cause now becomes a URL-driven tab instead of stacking the cards
vertically. Empty buckets are filtered out by the adapter already, so
the tab row only shows causes with hits. Active tab persists via
?tab=<cause_key>; refresh / share / back land on the same panel.

The standalone ReviewView container is no longer used (the page
renders the active CauseGroupCard directly); deleted.
This commit is contained in:
Claudio
2026-05-18 15:54:25 +00:00
parent ca9f6e6bd2
commit f9f276a986
2 changed files with 85 additions and 72 deletions

View File

@@ -1,48 +0,0 @@
<!--
Review-page body. Renders one CauseGroupCard per ReviewGroup the
adapter returned. Mirrors DuplicatesView's pending/error/empty
branching.
-->
<script lang="ts">
import type { ReviewGroup } from '$lib/services/adapters/review';
import type { PpPhoto } from '$lib/types/photoprism';
import CauseGroupCard from './CauseGroupCard.svelte';
interface Props {
groups: ReviewGroup[];
pending: boolean;
error: unknown;
onSelect?: (photo: PpPhoto) => void;
}
let { groups, pending, error, onSelect }: Props = $props();
const totalCount = $derived(groups.reduce((acc, g) => acc + g.photos.length, 0));
</script>
<div class="px-6 py-4 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if error}
<p class="text-sm text-destructive">
Could not load review queue: {error instanceof Error ? 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.
</p>
</div>
{:else}
<p class="mb-3 text-[11px] text-muted-foreground">
{totalCount} photos in {groups.length} groups · arrow keys to focus, S to approve, A
to archive, Enter to inspect
</p>
<div class="space-y-3">
{#each groups as g, i (g.cause)}
<CauseGroupCard group={g} autoFocus={i === 0} {onSelect} />
{/each}
</div>
{/if}
</div>

View File

@@ -1,14 +1,22 @@
<!-- <!--
/review — PhotoPrism's quality-flagged photo queue rendered as /review — PhotoPrism's quality-flagged photo queue, one tab per
cause-grouped cards (mirrors /duplicates). A click on any tile cause (mirrors /tags' pill row). Only causes with non-zero photos
focuses that photo in the existing selection store; the right-hand get a tab. Active tab is URL-driven via `?tab=<cause_key>` so
aside picks up the change and mounts RightSidebar in its existing refresh / share / back land on the same panel.
"single-photo metadata" mode, extended with related-photo strips
via `showRelated`. 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.
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { listReviewGroups } from '$lib/services/adapters/review'; import {
listReviewGroups,
type CauseKey,
type ReviewGroup
} 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 { selection, setFocused } from '$lib/stores/selection.svelte';
import { import {
@@ -21,38 +29,75 @@
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 RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import ReviewView from '$lib/components/review/ReviewView.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.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) { function onSelect(photo: PpPhoto) {
setFocused(photo.UID); setFocused(photo.UID);
} }
const reviewQuery = createQuery(() => ({ const reviewQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'], queryKey: ['review-groups'],
queryFn: listReviewGroups, queryFn: listReviewGroups,
enabled: isAuthenticated(), 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 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>(() => ({ const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''], queryKey: ['photo', selection.focused ?? ''],
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)), queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
enabled: isAuthenticated() && Boolean(selection.focused) enabled: isAuthenticated() && Boolean(selection.focused)
})); }));
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 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;
});
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> </script>
<Toolbar> <Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"> <span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review Review
</span> </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
type="button"
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)}
>
{t.label}
<span class="ml-1 text-muted-foreground/70">({t.count})</span>
</button>
{/each}
</div>
{/if}
{#snippet trailing()} {#snippet trailing()}
<div <div
class="flex items-center overflow-hidden rounded border border-border" class="flex items-center overflow-hidden rounded border border-border"
@@ -77,13 +122,29 @@
</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"> <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6">
<ReviewView {#if reviewQuery.isPending}
groups={reviewQuery.data ?? []} <p class="text-sm text-muted-foreground">Loading review queue…</p>
pending={reviewQuery.isPending} {:else if reviewQuery.error}
error={reviewQuery.error} <p class="text-sm text-destructive">
{onSelect} 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.
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} autoFocus {onSelect} />
{/key}
{/if}
</main> </main>
{#if !view.rightSidebarCollapsed && selection.focused} {#if !view.rightSidebarCollapsed && selection.focused}