feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
501
web/src/lib/actions/gridKeyNav.ts
Normal file
501
web/src/lib/actions/gridKeyNav.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import {
|
||||
addToHeap,
|
||||
likePhoto,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
||||
import {
|
||||
clearSelection,
|
||||
indexOf,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused,
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/**
|
||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||
*
|
||||
* - `scrollToIndex`: invoked when the action's own arrow-nav lands on a
|
||||
* tile that's currently windowed-out of the DOM. The host expands its
|
||||
* render window and scrolls the now-mounted shell into view.
|
||||
* - `onArrow`: when provided, the action delegates ALL arrow keys to the
|
||||
* host instead of computing moves itself. Required for grids with
|
||||
* interleaved non-tile rows (e.g. month headers): linear +/-cols math
|
||||
* skips wrong because the column count of header rows is 1 (full-span),
|
||||
* not the tile column count. The host owns the visual-row map and
|
||||
* handles the (row, col) translation. Mirrors mule-image's
|
||||
* `useGridKeyNav` pattern.
|
||||
*/
|
||||
export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
|
||||
|
||||
export interface GridKeyNavParams {
|
||||
scrollToIndex?: (i: number) => void;
|
||||
onArrow?: (key: ArrowKey, extending: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte `action` for the timeline grid. Owns:
|
||||
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
|
||||
* - Click + shift/ctrl click selection mutations
|
||||
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||
* x archive-toggle, u restore, f favorite-toggle, s + (1–9) add to
|
||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
||||
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
||||
* keyboard shortcuts.
|
||||
*
|
||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||
* 1. preview overlay uid (when open) — applies to the visible preview
|
||||
* photo even if the grid still shows a stale selection
|
||||
* 2. multi-selection set
|
||||
* 3. focused tile
|
||||
*/
|
||||
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
let scrollToIndex = params.scrollToIndex;
|
||||
let onArrow = params.onArrow;
|
||||
|
||||
/** Cached column count for the visible grid. Read from CSS
|
||||
* (`grid-template-columns` resolves to a space-separated list of px
|
||||
* sizes), invalidated by a `ResizeObserver` on the grid host. This
|
||||
* keeps the read DOM-cheap regardless of how many tiles are mounted —
|
||||
* critical once windowing renders only a slice of the order. */
|
||||
let cachedCols: number | null = null;
|
||||
let gridEl: HTMLElement | null = null;
|
||||
|
||||
function findGrid(): HTMLElement | null {
|
||||
// The `[role="group"][aria-label="Photos"]` or simply the first
|
||||
// element whose computed grid-template-columns has >1 track. The
|
||||
// timeline grid sits inside `node` (the action target = <main>).
|
||||
if (gridEl && node.contains(gridEl)) return gridEl;
|
||||
const candidate = node.querySelector<HTMLElement>('[data-photo-grid]');
|
||||
if (candidate) {
|
||||
gridEl = candidate;
|
||||
return candidate;
|
||||
}
|
||||
// Fallback: the first descendant that's display: grid with ≥2 cols.
|
||||
// Avoids a hard coupling on the data-attribute in case the host
|
||||
// hasn't tagged it yet.
|
||||
for (const el of node.querySelectorAll<HTMLElement>('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) {
|
||||
gridEl = el;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tilesPerRow(): number {
|
||||
if (cachedCols !== null) return cachedCols;
|
||||
const grid = findGrid();
|
||||
if (!grid) return 1;
|
||||
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length;
|
||||
cachedCols = Math.max(1, cols);
|
||||
return cachedCols;
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
// Container width changed → column count likely changed too.
|
||||
// Cheaper to invalidate than to recompute; tilesPerRow recomputes
|
||||
// on next access (which is per keystroke at most).
|
||||
cachedCols = null;
|
||||
});
|
||||
ro.observe(node);
|
||||
|
||||
function focusedIndex(): number {
|
||||
return indexOf(selection.focused);
|
||||
}
|
||||
|
||||
/** Move the focus cursor by `delta` tiles. When the move is NOT a
|
||||
* shift-extension, the anchor is bumped to the new focused tile so the
|
||||
* next shift-click/arrow starts from the user's current cursor (the
|
||||
* "starting photo") instead of a stale toggle/selectOnly anchor.
|
||||
*
|
||||
* Scroll-into-view tries the direct DOM lookup first (works pre-
|
||||
* windowing AND post-windowing for tiles already in the visible
|
||||
* window); if the tile isn't rendered (windowed out), defer to the
|
||||
* host-provided `scrollToIndex` which expands the window. */
|
||||
function moveFocus(delta: number, extending: boolean) {
|
||||
if (selection.order.length === 0) return;
|
||||
const cur = focusedIndex();
|
||||
const next =
|
||||
cur < 0
|
||||
? delta > 0
|
||||
? 0
|
||||
: selection.order.length - 1
|
||||
: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
|
||||
const nextUid = selection.order[next];
|
||||
setFocused(nextUid);
|
||||
if (!extending) setAnchor(nextUid);
|
||||
const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
|
||||
if (tile) {
|
||||
tile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
} else {
|
||||
scrollToIndex?.(next);
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize a target list. Preview wins, then multi, then focused. */
|
||||
function cullTargets(): string[] {
|
||||
if (preview.uid) return [preview.uid];
|
||||
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||
if (selection.focused) return [selection.focused];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Look up a photo's current cached state without forcing a refetch.
|
||||
* Walks every `['photos', …]` cache entry first, then the per-photo
|
||||
* cache. Lets `x` decide "archive vs restore" based on the actual current
|
||||
* state instead of always sending Archived=true.
|
||||
*
|
||||
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
|
||||
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
|
||||
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
|
||||
* scroll. Walk both — assuming a flat array on the timeline cache used
|
||||
* to throw `list.find is not a function` and abort the F/X handlers. */
|
||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||
}
|
||||
|
||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
const verb = direction === 'restore' ? 'restore' : 'archive';
|
||||
toast.message(`Nothing to ${verb}`, {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let target: boolean;
|
||||
if (direction === 'archive') target = true;
|
||||
else if (direction === 'restore') target = false;
|
||||
else {
|
||||
const first = cachedPhoto(ids[0]);
|
||||
target = !(first?.Archived ?? false);
|
||||
}
|
||||
|
||||
await patchTargets(
|
||||
ids,
|
||||
{ Archived: target },
|
||||
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
||||
(p) => ({ Archived: p.Archived ?? false })
|
||||
);
|
||||
}
|
||||
|
||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||
* target's cached `Favorite` to decide direction so a mixed selection
|
||||
* resolves to "make them all favorited" when the first isn't, mirroring
|
||||
* the way `toggleArchive('toggle')` works. */
|
||||
async function toggleFavoriteOnTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to favorite', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const first = cachedPhoto(ids[0]);
|
||||
const next = !(first?.Favorite ?? false);
|
||||
const { updated, errors } = await batchEdit(ids, (id) =>
|
||||
next ? likePhoto(id) : unlikePhoto(id)
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of ids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
const verb = next ? 'Favorited' : 'Unfavorited';
|
||||
if (errors.length) {
|
||||
// Surface the actual first error message — silent failures here are
|
||||
// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
|
||||
toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`${verb} ${ids.length}`);
|
||||
pushUndo(`${verb} ${ids.length}`, async () => {
|
||||
await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of ids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||
// Press S: arm a short timer. A digit 1–9 within the window adds the
|
||||
// cull targets to the Nth heap in the heap list. Any other key cancels
|
||||
// the chord without firing. On timeout, fall back to the currently-
|
||||
// viewed heap (i.e. when section==='heap'); otherwise show a hint toast.
|
||||
let sChordTimer: number | null = null;
|
||||
const S_CHORD_MS = 500;
|
||||
|
||||
function clearSChord() {
|
||||
if (sChordTimer !== null) {
|
||||
window.clearTimeout(sChordTimer);
|
||||
sChordTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addCullTargetsToHeap(heap: PpAlbum) {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to add', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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);
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function addCullTargetsToHeapByIndex(idx: number) {
|
||||
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
|
||||
if (idx < 1 || idx > heaps.length) {
|
||||
toast.message(`No heap #${idx}`);
|
||||
return;
|
||||
}
|
||||
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||
}
|
||||
|
||||
async function addCullTargetsToActiveHeap() {
|
||||
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||
toast.message('Press S then 1–9 to pick a heap');
|
||||
return;
|
||||
}
|
||||
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
|
||||
const heap = heaps.find((h) => h.UID === filters.heapUid);
|
||||
if (!heap) {
|
||||
toast.message('Active heap not found');
|
||||
return;
|
||||
}
|
||||
await addCullTargetsToHeap(heap);
|
||||
}
|
||||
|
||||
function openPreviewFromGrid() {
|
||||
const id = selection.focused ?? selection.order[0];
|
||||
if (!id) return;
|
||||
openPreview(id, selection.order);
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
if (preview.uid) closePreview();
|
||||
else openPreviewFromGrid();
|
||||
}
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
// Don't hijack typing inside form fields.
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
|
||||
// S+digit chord. A digit 1–9 within the chord window consumes the key
|
||||
// and fires add-to-heap-N. Any other key cancels the chord without
|
||||
// firing the default active-heap action — the user switched intent —
|
||||
// and falls through to normal handling for that key.
|
||||
if (sChordTimer !== null) {
|
||||
if (/^[1-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
clearSChord();
|
||||
void addCullTargetsToHeapByIndex(parseInt(e.key, 10));
|
||||
return;
|
||||
}
|
||||
clearSChord();
|
||||
}
|
||||
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
const shift = e.shiftKey;
|
||||
const inPreview = preview.uid !== null;
|
||||
|
||||
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
|
||||
if (!inPreview) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (onArrow) {
|
||||
// Host owns the visual-row map (needed for grids with
|
||||
// interleaved headers). The host calls setFocused +
|
||||
// scrollToIndex + selectRange-on-shift itself.
|
||||
onArrow(e.key, shift);
|
||||
} else {
|
||||
const delta =
|
||||
e.key === 'ArrowLeft'
|
||||
? -1
|
||||
: e.key === 'ArrowRight'
|
||||
? 1
|
||||
: e.key === 'ArrowUp'
|
||||
? -tilesPerRow()
|
||||
: tilesPerRow();
|
||||
moveFocus(delta, shift);
|
||||
if (shift && selection.focused) selectRange(selection.focused);
|
||||
}
|
||||
return;
|
||||
case 'Escape':
|
||||
clearSelection();
|
||||
setFocused(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode-aware shortcuts (work in grid AND preview) ──────────────────
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
togglePreview();
|
||||
return;
|
||||
case 'Tab':
|
||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||
// Browsers reserve Tab for focus traversal — preventDefault
|
||||
// here is fine because the grid owns this surface.
|
||||
e.preventDefault();
|
||||
toggleLeftSidebar();
|
||||
return;
|
||||
case 'i':
|
||||
case 'I':
|
||||
if (!meta && !shift && !inPreview) {
|
||||
e.preventDefault();
|
||||
toggleRightSidebar();
|
||||
}
|
||||
return;
|
||||
case 'b':
|
||||
case 'B':
|
||||
if (!meta && !shift) {
|
||||
e.preventDefault();
|
||||
toggleLeftSidebar();
|
||||
}
|
||||
return;
|
||||
case 'z':
|
||||
case 'Z':
|
||||
if (meta) {
|
||||
e.preventDefault();
|
||||
const entry = await popAndRun();
|
||||
if (entry) toast.success(`Undone: ${entry.label}`);
|
||||
else toast.message('Nothing to undo');
|
||||
}
|
||||
return;
|
||||
case 'a':
|
||||
case 'A':
|
||||
if (meta && !inPreview) {
|
||||
e.preventDefault();
|
||||
for (const id of selection.order) selection.ids.add(id);
|
||||
}
|
||||
return;
|
||||
case 'x':
|
||||
case 'X':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleArchive('toggle');
|
||||
return;
|
||||
case 'u':
|
||||
case 'U':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleFavoriteOnTargets();
|
||||
return;
|
||||
case 's':
|
||||
case 'S':
|
||||
if (meta || shift) return;
|
||||
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||
// otherwise we fall back to the currently-viewed heap.
|
||||
e.preventDefault();
|
||||
clearSChord();
|
||||
sChordTimer = window.setTimeout(() => {
|
||||
sChordTimer = null;
|
||||
void addCullTargetsToActiveHeap();
|
||||
}, S_CHORD_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const tile = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-tile]');
|
||||
if (!tile) return;
|
||||
const uid = tile.dataset.uid;
|
||||
if (!uid) return;
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
setFocused(uid);
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
} else if (selection.ids.size > 0) {
|
||||
// When a multi-selection is active, a plain click reduces it to
|
||||
// just this tile (matches mule-image's "selection mode" behaviour).
|
||||
e.preventDefault();
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('click', onClick);
|
||||
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
|
||||
// immediately on page load regardless of which element holds focus.
|
||||
// The filters inside `onKey` keep form-field typing and preview mode
|
||||
// safe (preview owns its own Arrow/Esc).
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
return {
|
||||
update(next: GridKeyNavParams = {}) {
|
||||
scrollToIndex = next.scrollToIndex;
|
||||
onArrow = next.onArrow;
|
||||
},
|
||||
destroy() {
|
||||
clearSChord();
|
||||
node.removeEventListener('click', onClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
ro.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user