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:
2026-05-17 16:06:58 +02:00
parent 423a73a8a6
commit 8c2526d982
69 changed files with 12048 additions and 0 deletions

View 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 + (19) 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 19 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 19 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 19 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 19 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();
}
};
}