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:
122
web/src/routes/notes/+page.svelte
Normal file
122
web/src/routes/notes/+page.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user