Compare commits
2 Commits
d35de8a2a9
...
a72619e3d1
| Author | SHA1 | Date | |
|---|---|---|---|
| a72619e3d1 | |||
| 84e433ff63 |
@@ -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'] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
115
web/src/lib/components/timeline/PhotoTile.svelte
Normal file
115
web/src/lib/components/timeline/PhotoTile.svelte
Normal 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>
|
||||||
31
web/src/lib/components/timeline/SkeletonGrid.svelte
Normal file
31
web/src/lib/components/timeline/SkeletonGrid.svelte
Normal 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>
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user