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:
@@ -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>
|
||||
@@ -1,14 +1,22 @@
|
||||
<!--
|
||||
/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`.
|
||||
/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.
|
||||
|
||||
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">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
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 { selection, setFocused } from '$lib/stores/selection.svelte';
|
||||
import {
|
||||
@@ -21,38 +29,75 @@
|
||||
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';
|
||||
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) {
|
||||
setFocused(photo.UID);
|
||||
}
|
||||
|
||||
const reviewQuery = createQuery(() => ({
|
||||
const reviewQuery = createQuery<ReviewGroup[]>(() => ({
|
||||
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)
|
||||
}));
|
||||
|
||||
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>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
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
|
||||
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()}
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded border border-border"
|
||||
@@ -77,13 +122,29 @@
|
||||
</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 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.
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} autoFocus {onSelect} />
|
||||
{/key}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if !view.rightSidebarCollapsed && selection.focused}
|
||||
|
||||
Reference in New Issue
Block a user