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>
This commit is contained in:
2026-05-20 11:20:02 +02:00
parent 55c870c155
commit d1ddc48f81
15 changed files with 607 additions and 512 deletions

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getPhoto,
listPhotosWithNotes,
type PhotoWithNote
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// Photos with a non-empty Details.Notes. The query is shared by the
// LeftSidebar's count badge ($derived off the same key), so visiting
// /notes warms the badge and vice-versa. Keyed under the ['photos', …]
// prefix so the existing mutation invalidations cascade in.
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
queryKey: ['photos', 'with-notes'],
queryFn: listPhotosWithNotes,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const items = $derived<PhotoWithNote[]>(notesQuery.data ?? []);
const count = $derived(items.length);
// Right-sidebar metadata for the focused tile. Same wiring as the tag
// drill-in page so the metadata panel reads consistently.
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0
}));
</script>
<Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Notes
</span>
{#if !notesQuery.isPending && !notesQuery.isError}
<span class="text-[11px] text-muted-foreground">
{count} photo{count === 1 ? '' : 's'}
</span>
{/if}
</Toolbar>
<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 p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if notesQuery.isPending}
<SkeletonGrid />
{:else if notesQuery.isError}
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load notes" />
{:else if items.length === 0}
<EmptyState
icon={StickyNote}
title="No photos with notes"
description="Add a note to a photo from its metadata sidebar and it will appear here."
/>
{:else}
<NotesPhotoGrid {items} />
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
<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…" />
{:else}
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/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
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
</div>

View File

@@ -115,7 +115,7 @@
count: g.photos.length as number | undefined
})),
{ id: 'stacks', label: 'Stacks', count: stacksCount },
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
]);
const requestedTab = $derived(page.url.searchParams.get('tab'));
@@ -212,7 +212,7 @@
<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 Cross-folder tabs above stay available for
here. The Stacks and Duplicates tabs above stay available for
duplicate cleanup.
</p>
{/snippet}
@@ -235,7 +235,7 @@
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/if}