- 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>
258 lines
8.9 KiB
Svelte
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}
|