From a72619e3d1c0a97bce0fd8652e9de97bedb474a4 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 17 May 2026 21:56:54 +0200 Subject: [PATCH] feat(timeline): focus follows archive, snaps to first on view load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New selection.focusAfter(excluded) walks selection.order forward past the archived/restored set so X-ing through the timeline keeps the cursor on the next live photo instead of falling back to photo[0] via the auto-anchor effect. Wired into gridKeyNav.toggleArchive (X key) and BulkActionBar.onArchive. - Auto-focus effect on the timeline always re-anchors to photos[0] on view load (pageCount → 1), instead of preserving a stale uid from the previous filter. - PhotoGrid re-anchors focus when the previously focused uid isn't in the new photo set, so drilling into a /tags category drops the cursor on its first tile instead of carrying a stale selection from whatever view the user came from. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/actions/gridKeyNav.ts | 30 +++++++-- .../sidebar/BulkMetadataSidebar.svelte | 8 +-- .../components/sidebar/RightSidebar.svelte | 17 +++-- .../components/timeline/BulkActionBar.svelte | 35 ++++++++-- .../lib/components/timeline/PhotoGrid.svelte | 21 ++++++ web/src/lib/services/photoprism.ts | 15 ++++- web/src/lib/stores/selection.svelte.ts | 48 ++++++++++++++ web/src/routes/+page.svelte | 64 +++++++++++++++++-- web/src/routes/tags/+page.svelte | 12 ++-- 9 files changed, 222 insertions(+), 28 deletions(-) diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 95fc936..274626a 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -17,6 +17,7 @@ import { filters } from '$lib/stores/filters.svelte'; import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte'; import { clearSelection, + focusAfter, indexOf, selectRange, selection, @@ -218,6 +219,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { toast.error(err instanceof Error ? err.message : 'Archive failed'); return; } + // Move focus forward before the photos query refetches, so the + // user can keep X-ing through the timeline without their cursor + // snapping back to photo[0]. Walks past every uid we just + // archived/restored — relevant when the cull targets came from a + // multi-selection rather than the single focused tile. + focusAfter(ids); invalidatePhotos(ids); const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; toast.success(label); @@ -344,12 +351,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { return; } try { - await addToHeap(heap.UID, ids); + const { added } = await addToHeap(heap.UID, ids); void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] }); - toast.success(`Added ${ids.length} → ${heap.Title}`); - pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => { - await removeFromHeap(heap.UID, ids); + // PhotoPrism returns 200 even when nothing was added — distinguish + // "really added N" from "skipped all N" so the toast tells the + // truth. + if (added.length === 0) { + toast.error(`Nothing added to ${heap.Title}`, { + description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).` + }); + return; + } + if (added.length < ids.length) { + toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, { + description: 'The rest were already in this heap.' + }); + } else { + toast.success(`Added ${added.length} → ${heap.Title}`); + } + pushUndo(`Added ${added.length} to ${heap.Title}`, async () => { + await removeFromHeap(heap.UID, added); void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] }); }); diff --git a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte index 8084be6..b6a07c6 100644 --- a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte +++ b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte @@ -119,10 +119,10 @@ } const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [ - { key: 'red', bg: 'bg-red-500', title: 'Red' }, - { key: 'orange', bg: 'bg-orange-500', title: 'Orange' }, - { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' }, - { key: 'green', bg: 'bg-green-500', title: 'Green' } + { key: 'red', bg: 'bg-red-500', title: 'Red — reject' }, + { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' }, + { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' }, + { key: 'green', bg: 'bg-green-500', title: 'Green — keep' } ]; async function applyKeyword() { diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index 2e6b0e1..894276a 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -15,6 +15,7 @@ ExternalLink, Heart, ImageIcon, + Loader2, Lock, MapPin, Star, @@ -244,11 +245,14 @@ void applyMark({ color: value }); } + // Tooltips follow the Lightroom culling convention so the swatches + // read as actions, not just colors. Red = reject, Yellow = pick, + // Green = keep, Orange = review-later. const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [ - { key: 'red', bg: 'bg-red-500', title: 'Red' }, - { key: 'orange', bg: 'bg-orange-500', title: 'Orange' }, - { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' }, - { key: 'green', bg: 'bg-green-500', title: 'Green' } + { key: 'red', bg: 'bg-red-500', title: 'Red — reject' }, + { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' }, + { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' }, + { key: 'green', bg: 'bg-green-500', title: 'Green — keep' } ]; const photoMark = $derived(marksQuery.data?.[photo.UID] ?? {}); @@ -314,6 +318,11 @@ onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} title={renaming ? 'Renaming…' : 'Click to rename file on disk'} /> + + {#if renaming} + + {/if} {/if} -
+ + {/each} + + {/if}
{#snippet trailing()} diff --git a/web/src/routes/tags/+page.svelte b/web/src/routes/tags/+page.svelte index f51d6c7..a0b17f7 100644 --- a/web/src/routes/tags/+page.svelte +++ b/web/src/routes/tags/+page.svelte @@ -138,11 +138,15 @@ return out; } + // Titles follow the Lightroom culling convention so users see the + // swatch's *intent* (reject/review/pick/keep), not just its color. + // Used both as tooltip on swatches and as the visible card label in + // the colors-tab picker grid below. const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [ - { key: 'red', bg: 'bg-red-500', title: 'Red' }, - { key: 'orange', bg: 'bg-orange-500', title: 'Orange' }, - { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' }, - { key: 'green', bg: 'bg-green-500', title: 'Green' } + { key: 'red', bg: 'bg-red-500', title: 'Red — reject' }, + { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' }, + { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' }, + { key: 'green', bg: 'bg-green-500', title: 'Green — keep' } ]; interface ColorGroup { key: string;