2 Commits

Author SHA1 Message Date
a72619e3d1 feat(timeline): focus follows archive, snaps to first on view load
- 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) <noreply@anthropic.com>
2026-05-17 21:56:54 +02:00
84e433ff63 fix(timeline): scroll-driven visibility scan, factor PhotoTile + SkeletonGrid
- visibleRange action rewritten to scan [data-uid-shell] divs on each
  rAF-throttled scroll instead of attaching an IntersectionObserver to
  sample tiles. The observer approach broke on return from /inbox: with
  cached photo data, shells mounted in the same Svelte pass as the
  scroll root and tileRegister fired before any __visibleRange stash
  was in place, so registrations dropped silently. Fast scrolling could
  also strand the observer in a dead zone when every sample tile left
  the viewport before the next was mounted. Shells are always rendered,
  so a DOM scan always finds a true first/last.
- Extract PhotoTile + SkeletonGrid so the timeline and the drill-in
  PhotoGrid share one tile chrome (selection animation, badges,
  hover-only "open preview" affordance).
- FolderTree count badge moves inside the row's button so the badge
  area becomes part of the click target instead of a dead zone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:33:03 +02:00
14 changed files with 584 additions and 316 deletions

View File

@@ -17,6 +17,7 @@ import { filters } from '$lib/stores/filters.svelte';
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte'; import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
import { import {
clearSelection, clearSelection,
focusAfter,
indexOf, indexOf,
selectRange, selectRange,
selection, selection,
@@ -218,6 +219,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
toast.error(err instanceof Error ? err.message : 'Archive failed'); toast.error(err instanceof Error ? err.message : 'Archive failed');
return; 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); invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
toast.success(label); toast.success(label);
@@ -344,12 +351,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return; return;
} }
try { try {
await addToHeap(heap.UID, ids); const { added } = await addToHeap(heap.UID, ids);
void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] });
toast.success(`Added ${ids.length}${heap.Title}`); // PhotoPrism returns 200 even when nothing was added — distinguish
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => { // "really added N" from "skipped all N" so the toast tells the
await removeFromHeap(heap.UID, ids); // 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: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] });
}); });

View File

@@ -1,59 +1,57 @@
/** /**
* Tracks the first/last visible tile indices inside the attached scroll * Tracks the first/last visible tile indices inside the attached scroll
* container so the host can render only `[first - BUFFER, last + BUFFER]` * container so the host can render only `[first - BUFFER, last + BUFFER]`
* and leave the rest unmounted. Mirrors PhotoPrism's pattern in * and leave the rest unmounted.
* `frontend/src/component/photo/view/cards.vue`:
* *
* - One IntersectionObserver on the scroll root. * Implementation: rAF-throttled scroll listener that scans the shell
* - Observes every Nth tile (sample, not every tile, to keep observer * elements (every photo renders a `[data-uid-shell]` div regardless of
* overhead flat as the order grows). * the windowing window) and reports the first/last shell whose bounding
* - The host registers nodes as they mount via `register(el, index)` * rect intersects the scroll root.
* and unregisters via `unregister(el)`. *
* - The action calls `onChange(first, last)` whenever the visible * Why not an IntersectionObserver? Two real-world breakages:
* window updates. *
* 1. Late tile registration on remount. With cached photo data, the
* host's child shells mount in the same pass as the scroll root,
* racing the observer setup → registrations silently drop, observer
* sees nothing, the window never updates.
* 2. Observer dead zone on fast scroll. The observer fires only when a
* sample tile crosses the root boundary. If the user flicks the
* scroll faster than the host's buffer can extend the mounted set,
* every sample tile leaves the viewport before the next one is
* mounted, the host's `onChange` stops firing, and the timeline
* goes blank.
*
* Querying shells directly sidesteps both: shells are always mounted,
* the scan happens on every scroll tick, and the result is the true
* first/last regardless of how fast the user dragged.
* *
* Use it like: * Use it like:
* *
* const range = $state({ first: 0, last: 0 });
* <main use:visibleRange={{ * <main use:visibleRange={{
* onChange: (f, l) => { range.first = f; range.last = l; }, * onChange: (f, l) => { range.first = f; range.last = l; }
* sampleEvery: 5,
* }}> * }}>
* {#each photos as p, i} * {#each photos as p, i}
* <div data-uid-shell={p.UID}>
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER} * {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} use:registerTile={i} /> * <Tile {p} />
* {:else}
* <div style="height: {tileHeight}px"></div>
* {/if} * {/if}
* </div>
* {/each} * {/each}
* </main> * </main>
* *
* The returned controller exposes `register`/`unregister`/`expand` so the * The `register`/`unregister` handle is kept for backwards compatibility
* host can plumb them through. * with the existing host but is now a no-op — shell-based scanning
* doesn't need per-tile enrolment.
*/ */
export interface VisibleRangeParams { export interface VisibleRangeParams {
onChange: (first: number, last: number) => void; onChange: (first: number, last: number) => void;
/** Sample 1 in N tiles. Higher numbers reduce observer overhead at /** Retained for backwards compatibility — shell-scan ignores it. */
* the cost of resolution. PhotoPrism uses 5. */
sampleEvery?: number; sampleEvery?: number;
/** Optional override: trigger zone in px around the scroll root. /** CSS margin string for legacy callers; shell-scan ignores it. */
* Defaults to 0 (only counts the visible viewport). */
rootMargin?: string; rootMargin?: string;
} }
export interface VisibleRangeController {
register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void;
/** Force the window to include `index` (used by keyboard navigation
* before scrollIntoView so the target tile actually exists). The
* host should expand its own `first`/`last` reactive state — the
* action only tracks observed intersections. */
}
/** Public handle the host attaches to each tile to enrol it in the
* visibility observer. Returned by setup() rather than created here so
* it closes over the active observer instance. */
export interface VisibleRangeHandle { export interface VisibleRangeHandle {
register(el: HTMLElement, index: number): void; register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void; unregister(el: HTMLElement): void;
@@ -61,112 +59,78 @@ export interface VisibleRangeHandle {
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) { export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
let current = params; let current = params;
const indexByEl = new WeakMap<Element, number>();
const visibleIndices = new Set<number>();
let observer: IntersectionObserver | null = null;
let lastFirst = -1; let lastFirst = -1;
let lastLast = -1; let lastLast = -1;
let rafId: number | null = null;
function rebuild() { function compute() {
observer?.disconnect(); rafId = null;
observer = new IntersectionObserver( const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
(entries) => { if (shells.length === 0) return;
let dirty = false; const rootRect = node.getBoundingClientRect();
for (const e of entries) { // Sweep through shells (rendered in document order = photo order)
const i = indexByEl.get(e.target); // and find the first/last whose rect crosses the viewport. Bail
if (i === undefined) continue; // out the moment we pass the bottom edge — shells past the
const wasIn = visibleIndices.has(i); // viewport can't intersect, no point measuring them.
if (e.isIntersecting && !wasIn) { let first = -1;
visibleIndices.add(i); let last = -1;
dirty = true; for (let i = 0; i < shells.length; i++) {
} else if (!e.isIntersecting && wasIn) { const r = shells[i].getBoundingClientRect();
visibleIndices.delete(i); if (r.bottom < rootRect.top) continue;
dirty = true; if (r.top > rootRect.bottom) break;
} if (first === -1) first = i;
} last = i;
if (!dirty) return;
emit();
},
{
root: node,
rootMargin: current.rootMargin ?? '0px'
}
);
}
function emit() {
if (visibleIndices.size === 0) {
// Don't emit (0, 0) — the host's last known window stays valid
// and the user is likely between layout passes. Once a sample
// tile re-enters view, the next intersection fires and we
// update for real.
return;
}
let first = Number.POSITIVE_INFINITY;
let last = Number.NEGATIVE_INFINITY;
for (const i of visibleIndices) {
if (i < first) first = i;
if (i > last) last = i;
} }
if (first === -1 || last === -1) return;
if (first === lastFirst && last === lastLast) return; if (first === lastFirst && last === lastLast) return;
lastFirst = first; lastFirst = first;
lastLast = last; lastLast = last;
current.onChange(first, last); current.onChange(first, last);
} }
rebuild(); function schedule() {
if (rafId !== null) return;
const handle: VisibleRangeHandle = { rafId = requestAnimationFrame(compute);
register(el, index) {
const every = current.sampleEvery ?? 5;
// Sample 1-in-N tiles. The host blindly calls register for
// every mounted tile; we only attach the observer to the
// sample subset to keep observer load O(n/N).
if (index % every !== 0) return;
indexByEl.set(el, index);
observer?.observe(el);
},
unregister(el) {
if (!indexByEl.has(el)) return;
const i = indexByEl.get(el);
if (i !== undefined) visibleIndices.delete(i);
indexByEl.delete(el);
observer?.unobserve(el);
emit();
} }
};
// Stash the handle on the node so the host can grab it via the // Initial measurement. Two rAFs because the first runs *during* the
// action's return. Svelte's action API only returns update/destroy, // current frame's mount cycle — shells may not have computed layout
// so we expose `getHandle` through a one-shot accessor on the host. // yet, so `getBoundingClientRect` returns zeros. Bouncing once more
// lets the browser finish layout before we measure.
requestAnimationFrame(() => requestAnimationFrame(compute));
node.addEventListener('scroll', schedule, { passive: true });
// Resize / content changes (new pages loaded, sidebar toggled,
// thumbnail size flipped) also shift the visible band — recompute.
const ro = new ResizeObserver(schedule);
ro.observe(node);
// No-op handle preserved so host code (`tileRegister`) doesn't need
// to change shape. Shell-scan reads geometry directly; per-tile
// registration isn't needed.
const handle: VisibleRangeHandle = {
register() {},
unregister() {}
};
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle; (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
return { return {
update(next: VisibleRangeParams) { update(next: VisibleRangeParams) {
const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5);
const marginChanged = next.rootMargin !== current.rootMargin;
current = next; current = next;
if (sampleChanged || marginChanged) {
// Re-observe everything under the new config. Cheapest is
// to disconnect; the host's tile-mount effects will
// re-register on next paint when they read sampleEvery.
observer?.disconnect();
indexByEl as unknown; // no-op; entries stay valid for the rebuild
visibleIndices.clear();
lastFirst = -1;
lastLast = -1;
rebuild();
}
}, },
destroy() { destroy() {
observer?.disconnect(); if (rafId !== null) cancelAnimationFrame(rafId);
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange; node.removeEventListener('scroll', schedule);
ro.disconnect();
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle })
.__visibleRange;
} }
}; };
} }
/** Read the handle the action stashed on the scroll-root node. Used by /** Read the handle the action stashed on the scroll-root node. Kept for
* the host's per-tile register/unregister calls. */ * callers that still want the (now-no-op) register/unregister surface;
* new callers can ignore this entirely. */
export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null { export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null {
if (!node) return null; if (!node) return null;
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null; return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;

View File

@@ -142,15 +142,19 @@
left-align with the Views/Heaps rows. --> left-align with the Views/Heaps rows. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span> <span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if} {/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge was previously a dead
zone right where the user's eye lands.
-->
<button <button
class="flex min-w-0 flex-1 items-center truncate text-left" class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasChildren || depth > 0} class:px-1={hasChildren || depth > 0}
onclick={() => onPick(node.path)} onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)} ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path} title={node.path}
> >
<span class="truncate">{node.name}</span> <span class="truncate">{node.name}</span>
</button>
{#if counts && counts[node.path] !== undefined} {#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]} {@const n = counts[node.path]}
<span <span
@@ -161,6 +165,7 @@
{n >= 1000 ? '1000+' : n} {n >= 1000 ? '1000+' : n}
</span> </span>
{/if} {/if}
</button>
{#if !readonly} {#if !readonly}
<!-- Hover-revealed kebab. `display: none` until row hover <!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]]) (or while the menu is open via has-[[data-state=open]])

View File

@@ -424,7 +424,17 @@
{/snippet} {/snippet}
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<nav class="flex-1 space-y-3 overflow-y-auto p-3"> <!--
Soft fade at the bottom of the scrolling nav so users with hidden
scrollbars (default on macOS) get a visual cue that there's more
content below the fold — common when the Heaps list grows long.
No JS / scroll listener; the trade-off is the last ~16px is always
slightly faded even at scroll-bottom.
-->
<nav
class="flex-1 space-y-3 overflow-y-auto p-3"
style="mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);"
>
<!-- Folders — top of the sidebar because the root folder is the <!-- Folders — top of the sidebar because the root folder is the
default landing view (see filters store init), making it the default landing view (see filters store init), making it the
primary navigation surface. Root-folder row + subfolder tree; primary navigation surface. Root-folder row + subfolder tree;
@@ -478,15 +488,19 @@
{rootExpanded ? '▾' : '▸'} {rootExpanded ? '▾' : '▸'}
</button> </button>
{/if} {/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge is the most visually
prominent element on the row and was previously a dead zone.
-->
<button <button
type="button" type="button"
class="flex min-w-0 flex-1 items-center truncate text-left" class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasSubfolders} class:px-1={hasSubfolders}
onclick={() => pickFolder('/')} onclick={() => pickFolder('/')}
title="Photos directly under originals/" title="Photos directly under originals/"
> >
<span class="truncate">/</span> <span class="truncate">/</span>
</button>
{#if configQuery.data} {#if configQuery.data}
<span <span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
@@ -496,6 +510,7 @@
{rootCount >= 1000 ? '1000+' : rootCount} {rootCount >= 1000 ? '1000+' : rootCount}
</span> </span>
{/if} {/if}
</button>
<!-- Root-row kebab. Only "New subfolder" applies — root itself <!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted can't be renamed or deleted, so those entries are omitted
entirely rather than greyed out. Hidden until row hover (or entirely rather than greyed out. Hidden until row hover (or
@@ -600,12 +615,11 @@
{#each heapsQuery.data ?? [] as heap (heap.UID)} {#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)} {@const active = isActive('heap', heap.UID)}
<!-- <!--
Count + kebab share the right edge: count is the Count badge lives INSIDE the button (along with the
resting state, kebab swaps in on hover (or while the title) so clicking the badge navigates to the heap —
menu is open). Moving the count out of the inner previously the badge was a dead zone. Kebab stays a
button is what lets it reach the row's right edge sibling and swaps in on hover (or while the menu is
the way Views rows do — and the inner button still open), pushing the button slightly left.
owns the navigate-on-click area.
--> -->
<li <li
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent" class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -614,13 +628,12 @@
class:hover:bg-primary={active} class:hover:bg-primary={active}
> >
<button <button
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 text-left" class="flex min-w-0 flex-1 items-center gap-2 px-2 text-left"
onclick={() => navigateTo('heap', heap.UID)} onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)} ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`} title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
> >
<span class="truncate">{heap.Title}</span> <span class="truncate">{heap.Title}</span>
</button>
<span <span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground' ? 'bg-primary-foreground/15 text-primary-foreground'
@@ -628,6 +641,7 @@
> >
{heap.PhotoCount ?? 0} {heap.PhotoCount ?? 0}
</span> </span>
</button>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block"> <div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions"> <KebabMenu label="Heap actions">
<Item <Item

View File

@@ -119,10 +119,10 @@
} }
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [ const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' }, { key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' }, { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' }, { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' } { key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
]; ];
async function applyKeyword() { async function applyKeyword() {

View File

@@ -15,6 +15,7 @@
ExternalLink, ExternalLink,
Heart, Heart,
ImageIcon, ImageIcon,
Loader2,
Lock, Lock,
MapPin, MapPin,
Star, Star,
@@ -244,11 +245,14 @@
void applyMark({ color: value }); 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 }[] = [ const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' }, { key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' }, { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' }, { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' } { key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
]; ];
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {}); const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
@@ -314,6 +318,11 @@
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'} title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/> />
<!-- Inline spinner next to the filename so the user sees the rename
in flight without having to scan to the bottom of the sidebar. -->
{#if renaming}
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
{/if}
<button <button
class="rounded p-1 hover:bg-accent disabled:opacity-50" class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-red-500={photo.Favorite} class:text-red-500={photo.Favorite}

View File

@@ -14,7 +14,12 @@
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch'; import { batchEdit } from '$lib/services/batch';
import { clearSelection, selection, setFocused } from '$lib/stores/selection.svelte'; import {
clearSelection,
focusAfter,
selection,
setFocused
} from '$lib/stores/selection.svelte';
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte'; import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
@@ -98,6 +103,10 @@
await batchRestore(ids); await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });
}); });
// Advance focus to the photo immediately after the archived
// set before the multi-selection is dropped — lets the user
// keep stepping through the timeline with X.
focusAfter(ids);
clearSelection(); clearSelection();
toast.success(`Archived ${ids.length}`); toast.success(`Archived ${ids.length}`);
} catch (err) { } catch (err) {
@@ -173,11 +182,27 @@
heapPickerOpen = false; heapPickerOpen = false;
await withBusy(async () => { await withBusy(async () => {
try { try {
await addToHeap(heap.UID, ids); const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] }); qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(`Added ${ids.length}${heap.Title}`); // PhotoPrism returns 200 even when nothing was added (UIDs
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => { // already present or unknown to the index) — surface the
await removeFromHeap(heap.UID, ids); // real delta so the user isn't fooled by a green toast over
// a no-op.
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);
qc.invalidateQueries({ queryKey: ['heaps'] }); qc.invalidateQueries({ queryKey: ['heaps'] });
}); });
clearSelection(); clearSelection();

View File

@@ -1,16 +1,19 @@
<!-- <!--
Flat photo grid for views that don't need infinite-scroll windowing or Flat photo grid for views that don't need infinite-scroll windowing or
month headers — the drill-in screens in /colors, /tags, /ratings. Wears month headers — the drill-in screens in /tags. Wears the same tile look
the same tile look + click semantics as the timeline so the user gets + click semantics as the timeline via the shared PhotoTile so the user
selection rings, single-click select, dblclick preview, and arrow-key gets selection rings, single-click select, dblclick preview, hover-only
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing. open affordance, and arrow-key nav (via `gridKeyNav` on the scroll-root)
without per-route plumbing.
The grid carries `data-photo-grid` so gridKeyNav can measure its The grid carries `data-photo-grid` so gridKeyNav can measure its column
column count, and each tile carries `data-tile`+`data-uid` so the count, and each tile (rendered by PhotoTile) carries `data-tile`+
action's document-level click handler can pick up shift/cmd/ctrl `data-uid` so the action's document-level click handler can pick up
modifiers and route them through the shared selection helpers. shift/cmd/ctrl modifiers and route them through the shared selection
helpers.
--> -->
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { import {
isSelected, isSelected,
selection, selection,
@@ -19,9 +22,9 @@
setOrder setOrder
} from '$lib/stores/selection.svelte'; } from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte'; import { openPreview } from '$lib/stores/preview.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte'; import { view } from '$lib/stores/view.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { type PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
interface Props { interface Props {
photos: PpPhoto[]; photos: PpPhoto[];
@@ -38,6 +41,26 @@
const order = $derived(photos.map((p) => p.UID)); const order = $derived(photos.map((p) => p.UID));
$effect(() => { $effect(() => {
setOrder(order); setOrder(order);
// Re-anchor focus when the previously focused photo isn't part of
// this grid — covers drilling into a /tags category from any
// other view, where the selection.focused module state would
// otherwise leak across surfaces and the new grid would render
// with no tile highlighted. Crucially we only re-anchor when the
// uid is *absent*, so refetches that keep the focused photo
// around (e.g. after `focusAfter` set the next photo on archive)
// don't snap focus back to photos[0].
untrack(() => {
if (order.length === 0) {
setFocused(null);
selection.ids.clear();
return;
}
const cur = selection.focused;
if (cur && order.includes(cur)) return;
setFocused(order[0]);
setAnchor(order[0]);
selection.ids.clear();
});
}); });
function onClick(e: MouseEvent, uid: string) { function onClick(e: MouseEvent, uid: string) {
@@ -56,51 +79,23 @@
e.preventDefault(); e.preventDefault();
openPreview(uid, order); openPreview(uid, order);
} }
function onOpenPreview(uid: string) {
openPreview(uid, order);
}
</script> </script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};"> <div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each photos as photo (photo.UID)} {#each photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID} {@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<button <div class="aspect-square">
type="button" <PhotoTile
data-tile {photo}
data-uid={photo.UID} selected={sel}
onclick={(e) => onClick(e, photo.UID)} onClick={(e) => onClick(e, photo.UID)}
ondblclick={(e) => onDblclick(e, photo.UID)} onDblclick={(e) => onDblclick(e, photo.UID)}
class:scale-90={sel} onOpenPreview={() => onOpenPreview(photo.UID)}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/> />
{#if sel} </div>
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/each} {/each}
</div> </div>

View File

@@ -0,0 +1,115 @@
<!--
Single photo tile — shared by the timeline (+page.svelte) and the flat
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
and can't drift between the two. Owns: thumbnail, selection animation,
favorite/video badges, and the hover-only "open preview" affordance.
Sizing is delegated to the caller — PhotoTile fills its container with
h-full w-full, so the timeline can wrap it in its windowing shell and
PhotoGrid can wrap it in an aspect-square cell.
Click semantics:
- plain click → onClick (caller decides; both surfaces use it for select-only)
- dblclick → onDblclick (caller usually opens preview)
- Maximize ico → onOpenPreview (single-click fallback for the
not-yet-discovered dblclick gesture)
-->
<script lang="ts">
import { Maximize2 } from 'lucide-svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
photo: PpPhoto;
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
onOpenPreview: () => void;
}
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
</script>
<!--
Wrapper div carries `group` so the Maximize affordance can hover-reveal
off the same hover region as the image-scale hover. The Maximize button
sits as a sibling of the main tile button (not nested) — nesting <button>
in <button> is invalid HTML.
-->
<div class="group relative h-full w-full">
<!--
Selection animation: scale to 90% + blue ring + blue tint overlay,
driven by a springy `cubic-bezier(0.34, 1.3, 0.64, 1)` over 300ms.
The overshoot is intentionally modest — on a *shrink* a larger
overshoot reads as the tile dipping below 90% before settling, which
is disorienting when many tiles transition at once (range-select).
The transition class is only applied while selected so deselect
snaps back instantly instead of crawling.
The keyboard-focused tile gets the same treatment as a selected one,
so the arrow-key cursor reads as a "selection of one".
-->
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={onClick}
ondblclick={onDblclick}
title="Click to select · Double-click to open"
class:scale-90={selected}
class:ring-2={selected}
class:ring-blue-500={selected}
class:ring-offset-2={selected}
class:ring-offset-background={selected}
class:transition-[transform,box-shadow]={selected}
class:duration-300={selected}
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={selected}
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!selected}
class:group-hover:scale-105={!selected}
/>
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
<!--
Hover-only "open preview" affordance. Single-click on this icon
opens the preview directly, giving users a one-click fallback for
the dblclick gesture (and a visual cue that previewing is a thing
at all). Hidden when the tile is selected — there'd be no preview
intent on a tile the user is in the middle of bulk-acting on.
-->
{#if !selected}
<button
type="button"
class="absolute bottom-1.5 right-1.5 hidden rounded bg-background/80 p-1 text-muted-foreground hover:text-foreground group-hover:block"
onclick={(e) => {
e.stopPropagation();
onOpenPreview();
}}
title="Open preview"
aria-label="Open preview"
>
<Maximize2 class="h-3 w-3" />
</button>
{/if}
</div>

View File

@@ -0,0 +1,31 @@
<!--
Loading skeleton for photo grids. Renders a small fixed batch of
pulsing tiles in the same column track as the real grid so the layout
doesn't shift when data arrives. Used on initial load only — the
infinite-scroll "Loading more…" sentinel at the bottom of the timeline
is a different signal and stays as text.
-->
<script lang="ts">
import { view } from '$lib/stores/view.svelte';
interface Props {
/** Number of skeleton tiles. Default ~first-viewport-worth. */
count?: number;
/** Override the column template (matches the real grid's CSS). */
columns?: string;
}
let { count = 20, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
</script>
<div
aria-hidden="true"
class="grid gap-2"
style="grid-template-columns: {tracks};"
>
{#each Array.from({ length: count }) as _, i (i)}
<div class="aspect-square animate-pulse rounded-md bg-secondary"></div>
{/each}
</div>

View File

@@ -500,8 +500,19 @@ export async function deleteHeap(uid: string): Promise<void> {
await http.delete(`/albums/${uid}`); await http.delete(`/albums/${uid}`);
} }
export async function addToHeap(uid: string, photos: string[]): Promise<void> { /**
await http.post(`/albums/${uid}/photos`, { photos }); * PhotoPrism returns `{ code, message, album, photos: [uids in album], added: [delta] }`.
* Surface `added` so callers can detect "200-but-nothing-happened" — PhotoPrism
* silently skips UIDs that are missing from the index or already in the album,
* which used to look like a successful add to the user.
*/
export interface AddToHeapResult {
added: string[];
}
export async function addToHeap(uid: string, photos: string[]): Promise<AddToHeapResult> {
const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos });
return { added: data.added ?? [] };
} }
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> { export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {

View File

@@ -113,3 +113,51 @@ export function setFocused(uid: string | null): void {
export function setAnchor(uid: string | null): void { export function setAnchor(uid: string | null): void {
selection.anchor = uid; selection.anchor = uid;
} }
/**
* Advance focus to the photo immediately after `excluded` in the current
* order, skipping any uid that's in `excluded`. Falls back to the closest
* non-excluded uid *before* the excluded set when the user is already at
* the tail. Returns `null` when nothing else is left.
*
* Used right after a mutation that removes the focused photo from the
* current view (archive / restore / approve / delete) — calling this
* *before* the photo cache refetches keeps focus stable instead of the
* effect-driven anchor falling back to photo[0].
*/
export function focusAfter(excluded: Iterable<string>): string | null {
const excludedSet = excluded instanceof Set ? excluded : new Set(excluded);
const order = selection.order;
if (order.length === 0) {
setFocused(null);
return null;
}
// Anchor index: prefer current focus, else the first excluded uid we
// can find (covers the case where focus was already null).
let anchorIdx = indexOf(selection.focused);
if (anchorIdx === -1) {
for (let i = 0; i < order.length; i++) {
if (excludedSet.has(order[i])) {
anchorIdx = i;
break;
}
}
}
if (anchorIdx === -1) return null;
for (let i = anchorIdx + 1; i < order.length; i++) {
if (!excludedSet.has(order[i])) {
setFocused(order[i]);
setAnchor(order[i]);
return order[i];
}
}
for (let i = anchorIdx - 1; i >= 0; i--) {
if (!excludedSet.has(order[i])) {
setFocused(order[i]);
setAnchor(order[i]);
return order[i];
}
}
setFocused(null);
return null;
}

View File

@@ -19,7 +19,7 @@
setSearch, setSearch,
setSection setSection
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { import {
isSelected, isSelected,
@@ -41,16 +41,14 @@
import { resizable } from '$lib/actions/resizable'; import { resizable } from '$lib/actions/resizable';
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav'; import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from '$lib/actions/nearBottom';
import { import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange';
visibleRange,
getVisibleRangeHandle,
type VisibleRangeHandle
} from '$lib/actions/visibleRange';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import PhotoTile from '$lib/components/timeline/PhotoTile.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.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 Toolbar from '$lib/components/layout/Toolbar.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { type PpPhoto } from '$lib/types/photoprism';
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store. // On nav (back/forward, deep link), reflect the URL into the store.
@@ -193,6 +191,13 @@
* append silently — we never want the focus to jump back to the top of * append silently — we never want the focus to jump back to the top of
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus) * the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
* from immediately re-triggering this effect. * from immediately re-triggering this effect.
*
* Always re-anchors to photos[0] when pageCount lands on 1 (which
* only happens on initial load or after a filter change resets the
* infinite-query) so switching views drops the user back at the top
* with a fresh focus cursor. Mid-flow mutations (archive / restore /
* etc.) advance focus themselves via `focusAfter` and don't flip
* pageCount, so they don't get clobbered by this re-anchor.
*/ */
$effect(() => { $effect(() => {
// Re-read pageCount so the effect bottoms out cleanly on filter // Re-read pageCount so the effect bottoms out cleanly on filter
@@ -206,10 +211,7 @@
// Only re-anchor focus on the very first page; later pages // Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0]. // must not pull focus back to photo[0].
if (pages !== 1) return; if (pages !== 1) return;
const cur = selection.focused;
if (!cur || !photos.some((p) => p.UID === cur)) {
setFocused(photos[0].UID); setFocused(photos[0].UID);
}
}); });
}); });
@@ -299,32 +301,38 @@
}); });
}); });
// Per-tile register handle exposed by the visibleRange action. The
// host pulls it off the scroll-root node once after mount.
let visHandle: VisibleRangeHandle | null = $state(null);
$effect(() => {
if (scrollRoot) visHandle = getVisibleRangeHandle(scrollRoot);
});
/** `use:tileRegister={i}` — stable-identity Svelte action that hooks /** `use:tileRegister={i}` — stable-identity Svelte action that hooks
* the tile shell into the visibility observer when it mounts and * the tile shell into the visibility observer when it mounts and
* un-hooks it when it unmounts (or when `i` changes because the * un-hooks it when it unmounts (or when `i` changes because the
* photos array shifted). Using a `use:` action (not `{@attach}`) * photos array shifted). Using a `use:` action (not `{@attach}`)
* keeps the registration stable across re-renders; `{@attach}` would * keeps the registration stable across re-renders; `{@attach}` would
* rebuild on every render because the inline arrow has fresh * rebuild on every render because the inline arrow has fresh
* identity each time. */ * identity each time.
*
* Resolve the handle lazily off the scroll root each call instead
* of stashing it in a `$state` populated by `$effect`. The effect
* runs *after* the DOM update flush, but on a remount with cached
* photo data the tiles render in the same pass as the scroll root,
* so a `$state`-backed handle is still `null` when tileRegister
* first fires — and the tile never enrols in the observer. Symptom
* was a blank grid on return-trip to the timeline (the bug this
* comment exists for). The visibleRange action sets
* `__visibleRange` on the scroll-root node during its setup phase,
* which runs before any child action, so a synchronous lookup
* always succeeds. */
function tileRegister(node: HTMLElement, index: number) { function tileRegister(node: HTMLElement, index: number) {
let current = index; let current = index;
visHandle?.register(node, current); const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
handle?.register(node, current);
return { return {
update(next: number) { update(next: number) {
if (next === current) return; if (next === current) return;
visHandle?.unregister(node); handle?.unregister(node);
current = next; current = next;
visHandle?.register(node, current); handle?.register(node, current);
}, },
destroy() { destroy() {
visHandle?.unregister(node); handle?.unregister(node);
} }
}; };
} }
@@ -633,6 +641,13 @@
openPreview(uid, photos.map((p) => p.UID)); openPreview(uid, photos.map((p) => p.UID));
} }
// Single-click fallback for the dblclick preview gesture. Wired to the
// hover-only Maximize icon in PhotoTile so users who haven't discovered
// dblclick can still get to the preview.
function onTileOpenPreview(uid: string) {
openPreview(uid, photos.map((p) => p.UID));
}
// Scroll root for the infinite-scroll IntersectionObserver. Bound by // Scroll root for the infinite-scroll IntersectionObserver. Bound by
// the <main> element below; the sentinel's `root` references this so // the <main> element below; the sentinel's `root` references this so
// the observer measures intersections relative to the timeline pane // the observer measures intersections relative to the timeline pane
@@ -647,12 +662,46 @@
e.preventDefault(); e.preventDefault();
setSearch(searchDraft.trim()); setSearch(searchDraft.trim());
} }
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [
'label:dog',
'keyword:vacation',
'taken:2024',
'"exact phrase"'
];
let searchFocused = $state(false);
function onSearchFocus() {
searchFocused = true;
}
function onSearchBlur() {
// Defer so a click on an example fires before the popover unmounts.
setTimeout(() => (searchFocused = false), 120);
}
function applySearchExample(ex: string) {
searchDraft = ex;
setSearch(ex);
searchFocused = false;
}
</script> </script>
<Toolbar showRightToggle> <Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"> <span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
{sectionLabel} {sectionLabel}
</span> </span>
<!--
Persistent gesture hint. The new click-semantics (single = select,
double = open) aren't intuitive for users arriving from Google
Photos / Apple Photos, so surface them in plain text where the eye
can see them without hover. Hidden below sm: so the search bar
still gets room on narrow viewports.
-->
{#if photos.length > 0}
<span class="hidden text-[10px] text-muted-foreground lg:inline">
click select · ⇧ range · ⌘ toggle · dblclick open
</span>
{/if}
{#if filters.section === 'archive' && photos.length > 0} {#if filters.section === 'archive' && photos.length > 0}
<button <button
type="button" type="button"
@@ -664,12 +713,14 @@
{emptyingArchive ? 'Emptying…' : 'Empty Archive'} {emptyingArchive ? 'Emptying…' : 'Empty Archive'}
</button> </button>
{/if} {/if}
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}> <form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input <input
type="search" type="search"
placeholder='Search · label:website / "vacation"' placeholder='Search · label:website / "vacation"'
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft} bind:value={searchDraft}
onfocus={onSearchFocus}
onblur={onSearchBlur}
/> />
<button <button
type="submit" type="submit"
@@ -690,6 +741,32 @@
</button> </button>
{/if} {/if}
<!--
Cheat-sheet popover: opens on input focus, lists working q-DSL
patterns. Clicking an example fills the input AND fires the
search, so it doubles as a one-click "try it" affordance.
-->
{#if searchFocused}
<div
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
>
<div class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Examples
</div>
{#each SEARCH_EXAMPLES as ex (ex)}
<button
type="button"
class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
onmousedown={(e) => {
e.preventDefault();
applySearchExample(ex);
}}
>
{ex}
</button>
{/each}
</div>
{/if}
</form> </form>
{#snippet trailing()} {#snippet trailing()}
@@ -739,7 +816,7 @@
> >
<div class="p-6 pb-24"> <div class="p-6 pb-24">
{#if photosQuery.isPending} {#if photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p> <SkeletonGrid />
{:else if photosQuery.isError} {:else if photosQuery.isError}
<p class="text-sm text-destructive"> <p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error Failed to load photos: {photosQuery.error instanceof Error
@@ -801,61 +878,14 @@
use:tileRegister={i} use:tileRegister={i}
> >
{#if inWindow} {#if inWindow}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID} {@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<!-- Selection animation ported from mule-image's PhotoThumbnail: <PhotoTile
scale to 90% + blue ring with offset + blue tint overlay, all {photo}
driven by a springy `cubic-bezier(0.34, 1.56, 0.64, 1)` over selected={sel}
300ms. Crucially, the transition class is ONLY applied when onClick={(e) => onTileClick(e, photo.UID)}
selected — dropping it on deselect snaps the photo back to onDblclick={(e) => onTileDblclick(e, photo.UID)}
full size instantly instead of crawling back. onOpenPreview={() => onTileOpenPreview(photo.UID)}
The keyboard-focused photo gets the same treatment, so the
arrow-key cursor reads as a "selection of one" (matches
mule-image, where focused == singular selection). -->
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onTileClick(e, photo.UID)}
ondblclick={(e) => onTileDblclick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/> />
<!-- Blue tint overlay (mule-image's primary selection
signal): pointer-events-none so clicks still hit the
button beneath. Rendered after the image so it composites
on top; before the badges so a star/heart still reads. -->
{#if sel}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@@ -17,6 +17,7 @@
import { gridKeyNav } from '$lib/actions/gridKeyNav'; import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte'; import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import Toolbar from '$lib/components/layout/Toolbar.svelte';
// Tag-flavoured surfaces, all under one route so the user can swap // Tag-flavoured surfaces, all under one route so the user can swap
@@ -137,11 +138,15 @@
return out; 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 }[] = [ const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' }, { key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' }, { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' }, { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' } { key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
]; ];
interface ColorGroup { interface ColorGroup {
key: string; key: string;
@@ -329,7 +334,7 @@
labels/keywords while ratings/colors resolve locally from the labels/keywords while ratings/colors resolve locally from the
marks pool already in cache. --> marks pool already in cache. -->
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending} {#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p> <SkeletonGrid />
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError} {:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos.</p> <p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0} {:else if drillPhotos.length === 0}
@@ -339,7 +344,7 @@
{/if} {/if}
{:else if activeTab === 'labels'} {:else if activeTab === 'labels'}
{#if labelsQuery.isPending} {#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p> <SkeletonGrid />
{:else if labelsQuery.isError} {:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p> <p class="text-sm text-destructive">Failed to load labels.</p>
{:else if labelsSorted.length === 0} {:else if labelsSorted.length === 0}
@@ -379,13 +384,7 @@
{/if} {/if}
{:else if activeTab === 'keywords'} {:else if activeTab === 'keywords'}
{#if keywordsQuery.isPending} {#if keywordsQuery.isPending}
<p class="text-sm text-muted-foreground"> <SkeletonGrid />
Loading keywords…
<br />
<span class="text-[11px]">
This walks every photo's metadata once — the result is cached after the first load.
</span>
</p>
{:else if keywordsQuery.isError} {:else if keywordsQuery.isError}
<p class="text-sm text-destructive">Failed to load keywords.</p> <p class="text-sm text-destructive">Failed to load keywords.</p>
{:else if keywordsSorted.length === 0} {:else if keywordsSorted.length === 0}
@@ -422,7 +421,7 @@
{/if} {/if}
{:else if activeTab === 'ratings'} {:else if activeTab === 'ratings'}
{#if marksQuery.isPending || marksPoolQuery.isPending} {#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p> <SkeletonGrid count={5} />
{:else if marksQuery.isError || marksPoolQuery.isError} {:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p> <p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if ratingGroups.length === 0} {:else if ratingGroups.length === 0}
@@ -463,7 +462,7 @@
{/if} {/if}
{:else if activeTab === 'colors'} {:else if activeTab === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending} {#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p> <SkeletonGrid count={4} />
{:else if marksQuery.isError || marksPoolQuery.isError} {:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p> <p class="text-sm text-destructive">Failed to load colors.</p>
{:else if colorGroups.length === 0} {:else if colorGroups.length === 0}