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 {
|
||||
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'] });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user