Files
mule-image/web/src/routes/review/+page.svelte
dtoro d1ddc48f81 web(review): switch cause tabs to PhotoGrid, add date-from-path suggestion
- CauseGroupCard now wraps PhotoGrid so selection, keyboard nav, and
  preview flow through the standard timeline plumbing. Per-tile hover
  Approve/Archive and the group-wide Approve all are gone — the bottom
  BulkActionBar's review-section Keep/Archive handle single + bulk.
- Low Resolution tab opts into a new PhotoTile dimensionBadge prop so
  WxH stays visible on each tile.
- New suggestDateFromPath util parses YYYY-MM-DD from filename or
  folder path. RightSidebar surfaces it as an amber Apply row above
  the Taken-at input whenever the photo lacks a trusted TakenAt.
- BulkActionBar gains a "Accept date & Keep" button (review section
  only) that patches each selected photo's TakenAt from its path
  suggestion when available, then approves.
- Drop the Same folder / Same camera / Same year strips and the
  RelatedStrip component from the metadata sidebar.

Also bundles in-progress Notes route + tile components and small
tweaks to LeftSidebar, DuplicatesView, CrossFolderGroupCard, and
photoprism.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:20:02 +02:00

258 lines
8.9 KiB
Svelte

<!--
/review — PhotoPrism's quality-flagged photo queue *plus* the
duplicate-resolution panels (stacks & cross-folder). Cause tabs and
duplicate tabs share the same pill row so the user has a single
"things to clean up" surface instead of two routes.
Cause tabs use the shared timeline machinery (PhotoGrid, gridKeyNav,
BulkActionBar, BulkMetadataSidebar); duplicate tabs are a different
flow (per-group card with its own action buttons) so they render in
a stripped-down layout with no BulkActionBar / right sidebar.
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.
-->
<script lang="ts">
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import {
listReviewGroups,
type CauseKey,
type ReviewGroup
} from '$lib/services/adapters/review';
import {
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import {
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.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';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Sparkles } from 'lucide-svelte';
type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab;
function isDupTab(t: Tab | null): t is DupTab {
return t === 'stacks' || t === 'cross-folder';
}
// 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'],
queryFn: listReviewGroups,
enabled: isAuthenticated(),
staleTime: 30_000
}));
// Stacks is a cheap PhotoPrism query so we run it eagerly — the
// "Stacks" tab badge needs the count even while the user is on a
// cause tab. Cross-folder is the expensive disk scan; the page
// observes its cache (enabled:false) and DuplicatesView is what
// triggers the actual scan when its tab is active.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
enabled: isAuthenticated(),
staleTime: 30_000
}));
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000
}));
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 ?? []);
const stacksCount = $derived(stacksQuery.data?.length);
const crossFolderCount = $derived(crossFolderQuery.data?.groups.length);
type TabSpec = { id: Tab; label: string; count: number | undefined };
const tabs = $derived<TabSpec[]>([
...groups.map((g) => ({
id: g.cause as Tab,
label: g.meta.title,
count: g.photos.length as number | undefined
})),
{ id: 'stacks', label: 'Stacks', count: stacksCount },
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
]);
const requestedTab = $derived(page.url.searchParams.get('tab'));
const activeTab: Tab = $derived.by(() => {
const want = tabs.find((t) => t.id === requestedTab);
return (want ?? tabs[0]).id;
});
const activeIsDup = $derived(isDupTab(activeTab));
// 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.
// Also fires when switching into a duplicates tab (where selection
// is meaningless anyway).
$effect(() => {
void activeTab;
clearSelection();
});
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review
</span>
{#if activeTabSpec}
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
{#if activeTabSpec.count !== undefined}
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
{/if}
{/if}
{#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>
<!--
Two layout branches:
• Cause tabs use the timeline-style layout (grid key nav, bulk
action bar, right metadata sidebar) since the user is acting on
individual photos.
• Duplicate tabs use a stripped layout — DuplicatesView renders
its own per-group cards with built-in actions, so the bulk bar
and right sidebar would just clutter.
-->
{#if activeIsDup}
<main class="min-h-0 flex-1 overflow-y-auto">
<DuplicatesView
activeTab={activeTab as DupTab}
groups={stacksQuery.data ?? []}
pending={stacksQuery.isPending}
error={stacksQuery.error}
/>
</main>
{:else}
<div class="flex min-h-0 flex-1">
<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}
<InlineLoader label="Loading review queue…" />
{:else if reviewQuery.error}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load review queue"
description={reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
/>
{:else if groups.length === 0}
<EmptyState icon={Sparkles} title="Nothing to review">
{#snippet descriptionSnippet()}
<p>
The indexer flags photos with a low quality score for human review. New
arrivals with missing EXIF, low resolution, or unknown cameras will land
here. The Stacks and Duplicates tabs above stay available for
duplicate cleanup.
</p>
{/snippet}
</EmptyState>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
<BulkActionBar />
</div>
{#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 selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/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>
{/if}