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 {
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'] });
});

View File

@@ -1,59 +1,57 @@
/**
* Tracks the first/last visible tile indices inside the attached scroll
* container so the host can render only `[first - BUFFER, last + BUFFER]`
* and leave the rest unmounted. Mirrors PhotoPrism's pattern in
* `frontend/src/component/photo/view/cards.vue`:
* and leave the rest unmounted.
*
* - One IntersectionObserver on the scroll root.
* - Observes every Nth tile (sample, not every tile, to keep observer
* overhead flat as the order grows).
* - The host registers nodes as they mount via `register(el, index)`
* and unregisters via `unregister(el)`.
* - The action calls `onChange(first, last)` whenever the visible
* window updates.
* Implementation: rAF-throttled scroll listener that scans the shell
* elements (every photo renders a `[data-uid-shell]` div regardless of
* the windowing window) and reports the first/last shell whose bounding
* rect intersects the scroll root.
*
* Why not an IntersectionObserver? Two real-world breakages:
*
* 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:
*
* const range = $state({ first: 0, last: 0 });
* <main use:visibleRange={{
* onChange: (f, l) => { range.first = f; range.last = l; },
* sampleEvery: 5,
* onChange: (f, l) => { range.first = f; range.last = l; }
* }}>
* {#each photos as p, i}
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} use:registerTile={i} />
* {:else}
* <div style="height: {tileHeight}px"></div>
* {/if}
* <div data-uid-shell={p.UID}>
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} />
* {/if}
* </div>
* {/each}
* </main>
*
* The returned controller exposes `register`/`unregister`/`expand` so the
* host can plumb them through.
* The `register`/`unregister` handle is kept for backwards compatibility
* with the existing host but is now a no-op — shell-based scanning
* doesn't need per-tile enrolment.
*/
export interface VisibleRangeParams {
onChange: (first: number, last: number) => void;
/** Sample 1 in N tiles. Higher numbers reduce observer overhead at
* the cost of resolution. PhotoPrism uses 5. */
/** Retained for backwards compatibility — shell-scan ignores it. */
sampleEvery?: number;
/** Optional override: trigger zone in px around the scroll root.
* Defaults to 0 (only counts the visible viewport). */
/** CSS margin string for legacy callers; shell-scan ignores it. */
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 {
register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void;
@@ -61,112 +59,78 @@ export interface VisibleRangeHandle {
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
let current = params;
const indexByEl = new WeakMap<Element, number>();
const visibleIndices = new Set<number>();
let observer: IntersectionObserver | null = null;
let lastFirst = -1;
let lastLast = -1;
let rafId: number | null = null;
function rebuild() {
observer?.disconnect();
observer = new IntersectionObserver(
(entries) => {
let dirty = false;
for (const e of entries) {
const i = indexByEl.get(e.target);
if (i === undefined) continue;
const wasIn = visibleIndices.has(i);
if (e.isIntersecting && !wasIn) {
visibleIndices.add(i);
dirty = true;
} else if (!e.isIntersecting && wasIn) {
visibleIndices.delete(i);
dirty = true;
}
}
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;
function compute() {
rafId = null;
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
if (shells.length === 0) return;
const rootRect = node.getBoundingClientRect();
// Sweep through shells (rendered in document order = photo order)
// and find the first/last whose rect crosses the viewport. Bail
// out the moment we pass the bottom edge — shells past the
// viewport can't intersect, no point measuring them.
let first = -1;
let last = -1;
for (let i = 0; i < shells.length; i++) {
const r = shells[i].getBoundingClientRect();
if (r.bottom < rootRect.top) continue;
if (r.top > rootRect.bottom) break;
if (first === -1) first = i;
last = i;
}
if (first === -1 || last === -1) return;
if (first === lastFirst && last === lastLast) return;
lastFirst = first;
lastLast = last;
current.onChange(first, last);
}
rebuild();
function schedule() {
if (rafId !== null) return;
rafId = requestAnimationFrame(compute);
}
// Initial measurement. Two rAFs because the first runs *during* the
// current frame's mount cycle — shells may not have computed layout
// 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(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();
}
register() {},
unregister() {}
};
// Stash the handle on the node so the host can grab it via the
// action's return. Svelte's action API only returns update/destroy,
// so we expose `getHandle` through a one-shot accessor on the host.
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
return {
update(next: VisibleRangeParams) {
const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5);
const marginChanged = next.rootMargin !== current.rootMargin;
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() {
observer?.disconnect();
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange;
if (rafId !== null) cancelAnimationFrame(rafId);
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
* the host's per-tile register/unregister calls. */
/** Read the handle the action stashed on the scroll-root node. Kept for
* 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 {
if (!node) return null;
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;

View File

@@ -142,25 +142,30 @@
left-align with the Views/Heaps rows. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/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
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}
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path}
>
<span class="truncate">{node.name}</span>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
</button>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
{#if !readonly}
<!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]])

View File

@@ -424,7 +424,17 @@
{/snippet}
<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
default landing view (see filters store init), making it the
primary navigation surface. Root-folder row + subfolder tree;
@@ -478,24 +488,29 @@
{rootExpanded ? '▾' : '▸'}
</button>
{/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
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}
onclick={() => pickFolder('/')}
title="Photos directly under originals/"
>
<span class="truncate">/</span>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
</button>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
<!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted
entirely rather than greyed out. Hidden until row hover (or
@@ -600,12 +615,11 @@
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<!--
Count + kebab share the right edge: count is the
resting state, kebab swaps in on hover (or while the
menu is open). Moving the count out of the inner
button is what lets it reach the row's right edge
the way Views rows do — and the inner button still
owns the navigate-on-click area.
Count badge lives INSIDE the button (along with the
title) so clicking the badge navigates to the heap —
previously the badge was a dead zone. Kebab stays a
sibling and swaps in on hover (or while the menu is
open), pushing the button slightly left.
-->
<li
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -614,20 +628,20 @@
class:hover:bg-primary={active}
>
<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)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
>
<span class="truncate">{heap.Title}</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
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
</button>
<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
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions">
<Item

View File

@@ -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() {

View File

@@ -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<PhotoMark>(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'}
/>
<!-- 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
class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-red-500={photo.Favorite}

View File

@@ -14,7 +14,12 @@
type PpAlbum
} from '$lib/services/photoprism';
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 { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
@@ -98,6 +103,10 @@
await batchRestore(ids);
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();
toast.success(`Archived ${ids.length}`);
} catch (err) {
@@ -173,11 +182,27 @@
heapPickerOpen = false;
await withBusy(async () => {
try {
await addToHeap(heap.UID, ids);
const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] });
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 (UIDs
// already present or unknown to the index) — surface the
// 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'] });
});
clearSelection();

View File

@@ -1,16 +1,19 @@
<!--
Flat photo grid for views that don't need infinite-scroll windowing or
month headers — the drill-in screens in /colors, /tags, /ratings. Wears
the same tile look + click semantics as the timeline so the user gets
selection rings, single-click select, dblclick preview, and arrow-key
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing.
month headers — the drill-in screens in /tags. Wears the same tile look
+ click semantics as the timeline via the shared PhotoTile so the user
gets selection rings, single-click select, dblclick preview, hover-only
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
column count, and each tile carries `data-tile`+`data-uid` so the
action's document-level click handler can pick up shift/cmd/ctrl
modifiers and route them through the shared selection helpers.
The grid carries `data-photo-grid` so gridKeyNav can measure its column
count, and each tile (rendered by PhotoTile) carries `data-tile`+
`data-uid` so the action's document-level click handler can pick up
shift/cmd/ctrl modifiers and route them through the shared selection
helpers.
-->
<script lang="ts">
import { untrack } from 'svelte';
import {
isSelected,
selection,
@@ -19,9 +22,9 @@
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { thumbUrl } from '$lib/stores/session.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 {
photos: PpPhoto[];
@@ -38,6 +41,26 @@
const order = $derived(photos.map((p) => p.UID));
$effect(() => {
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) {
@@ -56,51 +79,23 @@
e.preventDefault();
openPreview(uid, order);
}
function onOpenPreview(uid: string) {
openPreview(uid, order);
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onClick(e, photo.UID)}
ondblclick={(e) => onDblclick(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 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}
<div class="aspect-square">
<PhotoTile
{photo}
selected={sel}
onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)}
onOpenPreview={() => onOpenPreview(photo.UID)}
/>
{#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>
</div>
{/each}
</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}`);
}
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> {

View File

@@ -113,3 +113,51 @@ export function setFocused(uid: string | null): void {
export function setAnchor(uid: string | null): void {
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,
setSection
} 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 {
isSelected,
@@ -41,16 +41,14 @@
import { resizable } from '$lib/actions/resizable';
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
import { nearBottom } from '$lib/actions/nearBottom';
import {
visibleRange,
getVisibleRangeHandle,
type VisibleRangeHandle
} from '$lib/actions/visibleRange';
import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.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 SkeletonGrid from '$lib/components/timeline/SkeletonGrid.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 ──────────────────────────────────────────────
// 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
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
* 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(() => {
// 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
// must not pull focus back to photo[0].
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
* the tile shell into the visibility observer when it mounts and
* un-hooks it when it unmounts (or when `i` changes because the
* photos array shifted). Using a `use:` action (not `{@attach}`)
* keeps the registration stable across re-renders; `{@attach}` would
* 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) {
let current = index;
visHandle?.register(node, current);
const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
handle?.register(node, current);
return {
update(next: number) {
if (next === current) return;
visHandle?.unregister(node);
handle?.unregister(node);
current = next;
visHandle?.register(node, current);
handle?.register(node, current);
},
destroy() {
visHandle?.unregister(node);
handle?.unregister(node);
}
};
}
@@ -633,6 +641,13 @@
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
// the <main> element below; the sentinel's `root` references this so
// the observer measures intersections relative to the timeline pane
@@ -647,12 +662,46 @@
e.preventDefault();
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>
<Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
{sectionLabel}
</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}
<button
type="button"
@@ -664,12 +713,14 @@
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
</button>
{/if}
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}>
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input
type="search"
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"
bind:value={searchDraft}
onfocus={onSearchFocus}
onblur={onSearchBlur}
/>
<button
type="submit"
@@ -690,6 +741,32 @@
</button>
{/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>
{#snippet trailing()}
@@ -739,7 +816,7 @@
>
<div class="p-6 pb-24">
{#if photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p>
<SkeletonGrid />
{:else if photosQuery.isError}
<p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error
@@ -801,61 +878,14 @@
use:tileRegister={i}
>
{#if inWindow}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<!-- Selection animation ported from mule-image's PhotoThumbnail:
scale to 90% + blue ring with offset + blue tint overlay, all
driven by a springy `cubic-bezier(0.34, 1.56, 0.64, 1)` over
300ms. Crucially, the transition class is ONLY applied when
selected — dropping it on deselect snaps the photo back to
full size instantly instead of crawling back.
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>
<PhotoTile
{photo}
selected={sel}
onClick={(e) => onTileClick(e, photo.UID)}
onDblclick={(e) => onTileDblclick(e, photo.UID)}
onOpenPreview={() => onTileOpenPreview(photo.UID)}
/>
{/if}
</div>
{/if}

View File

@@ -17,6 +17,7 @@
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.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';
// Tag-flavoured surfaces, all under one route so the user can swap
@@ -137,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;
@@ -329,7 +334,7 @@
labels/keywords while ratings/colors resolve locally from the
marks pool already in cache. -->
{#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}
<p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0}
@@ -339,7 +344,7 @@
{/if}
{:else if activeTab === 'labels'}
{#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p>
<SkeletonGrid />
{:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p>
{:else if labelsSorted.length === 0}
@@ -379,13 +384,7 @@
{/if}
{:else if activeTab === 'keywords'}
{#if keywordsQuery.isPending}
<p class="text-sm text-muted-foreground">
Loading keywords…
<br />
<span class="text-[11px]">
This walks every photo's metadata once — the result is cached after the first load.
</span>
</p>
<SkeletonGrid />
{:else if keywordsQuery.isError}
<p class="text-sm text-destructive">Failed to load keywords.</p>
{:else if keywordsSorted.length === 0}
@@ -422,7 +421,7 @@
{/if}
{:else if activeTab === 'ratings'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p>
<SkeletonGrid count={5} />
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if ratingGroups.length === 0}
@@ -463,7 +462,7 @@
{/if}
{:else if activeTab === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p>
<SkeletonGrid count={4} />
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p>
{:else if colorGroups.length === 0}