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,107 @@
/**
* Filter state for the timeline. One source of truth; routes read from it,
* the left sidebar writes to it, and `filtersToQ()` derives the search
* string PhotoPrism's `q` parameter accepts.
*
* Sections behave like saved searches: picking a section sets a default
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
* etc.), and the search box on top stacks an additional `q` term.
*/
export type Section =
| 'all-photos'
| 'favorites'
| 'archive'
| 'heap';
export interface FilterState {
section: Section;
/** Heap UID, used when section === 'heap'. */
heapUid: string | null;
/** Relative folder path under originals/. Stacks with section terms. */
folderPath: string | null;
/** Free-form search text, ANDed with section-derived terms. */
search: string;
}
export const filters = $state<FilterState>({
section: 'all-photos',
heapUid: null,
folderPath: null,
search: ''
});
export function setSection(section: Section, heapUid: string | null = null): void {
filters.section = section;
filters.heapUid = section === 'heap' ? heapUid : null;
}
export function setSearch(q: string): void {
filters.search = q;
}
export function setFolderPath(path: string | null): void {
filters.folderPath = path;
}
/**
* Quote a DSL term value when it contains characters that PhotoPrism's
* parser treats as boundaries (spaces, colons). We surround in double
* quotes; users can still type a raw `q=` for advanced search.
*/
function quoteIfNeeded(v: string): string {
if (!v) return '';
if (/^[A-Za-z0-9_\-./]+$/.test(v)) return v;
return `"${v.replace(/"/g, '\\"')}"`;
}
/**
* Build the PhotoPrism `q=` DSL string from the current filter state.
* Returns "" when nothing's restricting (the timeline default).
*/
export function filtersToQ(f: FilterState = filters): string {
const parts: string[] = [];
switch (f.section) {
case 'favorites':
parts.push('favorite:true');
break;
case 'archive':
parts.push('archived:true');
break;
case 'heap':
if (f.heapUid) parts.push(`album:${f.heapUid}`);
break;
case 'all-photos':
default:
break;
}
if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
if (f.search) parts.push(quoteIfNeeded(f.search));
return parts.join(' ');
}
/** Inverse of filtersToQ for URL hydration. Returns the parsed filter state. */
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
const sectionRaw = params.get('section') as Section | null;
const section: Section =
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
? sectionRaw
: 'all-photos';
return {
section,
heapUid: params.get('heap'),
folderPath: params.get('folder'),
search: params.get('q') ?? ''
};
}
/** Serialise the current filter state to URL search params (only set keys
* that differ from defaults so the URL stays clean). */
export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
const params = new URLSearchParams();
if (f.section !== 'all-photos') params.set('section', f.section);
if (f.heapUid) params.set('heap', f.heapUid);
if (f.folderPath) params.set('folder', f.folderPath);
if (f.search) params.set('q', f.search);
return params;
}

View File

@@ -0,0 +1,36 @@
/**
* Single-photo preview overlay state. The lightbox sits in +layout.svelte
* and listens to this store; any view (timeline, duplicates view, heaps)
* can `open(uid)` to pop it. The order array is mirrored from whatever
* list the user is currently looking at so prev/next stay in context.
*/
export const preview = $state<{
uid: string | null;
order: string[];
}>({
uid: null,
order: []
});
export function openPreview(uid: string, order?: string[]): void {
if (order) preview.order = order;
preview.uid = uid;
}
export function closePreview(): void {
preview.uid = null;
}
export function previewNext(): void {
if (!preview.uid) return;
const i = preview.order.indexOf(preview.uid);
if (i < 0 || i >= preview.order.length - 1) return;
preview.uid = preview.order[i + 1];
}
export function previewPrev(): void {
if (!preview.uid) return;
const i = preview.order.indexOf(preview.uid);
if (i <= 0) return;
preview.uid = preview.order[i - 1];
}

View File

@@ -0,0 +1,115 @@
import { SvelteSet } from 'svelte/reactivity';
/**
* Multi-select state for the timeline + duplicate views. Tracks which photo
* UIDs are picked, plus an anchor for shift-range extension and a focus
* cursor for arrow-key navigation. Components import `selection` and read
* its reactive fields; mutations go through the helpers below.
*
* SvelteSet is required (not plain Set) so component-level `selection.ids`
* reads re-run when membership changes.
*/
export const selection = $state<{
ids: SvelteSet<string>;
anchor: string | null;
focused: string | null;
/**
* Mirror of the active ordered photo list, kept in sync by the timeline.
* Needed for shift-range extension and arrow-key navigation.
*/
order: string[];
}>({
ids: new SvelteSet<string>(),
anchor: null,
focused: null,
order: []
});
/**
* Side index of `order`. Rebuilt by `setOrder`. Keeping the array around
* (rather than dropping it entirely) lets preview navigation and
* `cullTargets` iterate cheaply; the map exists solely to take
* `selectRange` and arrow-key navigation from O(n) to O(1) on large
* libraries. Not reactive — only `setOrder` reads/writes it.
*/
const orderIndex = new Map<string, number>();
/** O(1) index lookup. Returns -1 when the uid isn't in the current order
* (consistent with `Array.indexOf`). */
export function indexOf(uid: string | null): number {
if (uid === null) return -1;
const i = orderIndex.get(uid);
return i === undefined ? -1 : i;
}
export function isSelected(uid: string): boolean {
return selection.ids.has(uid);
}
export function clearSelection(): void {
selection.ids.clear();
selection.anchor = null;
}
export function toggle(uid: string): void {
if (selection.ids.has(uid)) {
selection.ids.delete(uid);
} else {
selection.ids.add(uid);
selection.anchor = uid;
}
}
export function selectOnly(uid: string): void {
selection.ids.clear();
selection.ids.add(uid);
selection.anchor = uid;
}
export function selectRange(uid: string): void {
// When the user hasn't explicitly anchored (no toggle/selectOnly before
// this shift-click), treat the focused tile as the anchor — that's the
// "starting photo" the user just clicked or arrow-keyed to. Without this
// fallback, shift-clicking after a plain click would select only the
// shift-clicked tile and the starting photo would be dropped.
const anchor = selection.anchor ?? selection.focused;
if (!anchor) {
selectOnly(uid);
return;
}
const a = indexOf(anchor);
const b = indexOf(uid);
if (a < 0 || b < 0) {
selectOnly(uid);
return;
}
const [lo, hi] = a < b ? [a, b] : [b, a];
selection.ids.clear();
for (let i = lo; i <= hi; i++) selection.ids.add(selection.order[i]);
// Promote the anchor we used so subsequent shift-clicks keep the same
// start point (otherwise focus-as-anchor would drift each move).
selection.anchor = anchor;
}
export function setOrder(order: string[]): void {
selection.order = order;
// Rebuild the side index. `clear` + per-element `set` is O(n) and
// allocation-free vs `new Map(order.map(...))` which would churn GC
// on every page append in the infinite-scroll path.
orderIndex.clear();
for (let i = 0; i < order.length; i++) orderIndex.set(order[i], i);
}
export function setFocused(uid: string | null): void {
selection.focused = uid;
}
/**
* Promote a uid to the shift-range anchor without adding it to the selection.
* Used on plain click + arrow-key navigation so the next shift-click extends
* from the user's most recent interaction (the "starting photo"), even when
* the selection set is empty.
*/
export function setAnchor(uid: string | null): void {
selection.anchor = uid;
}

View File

@@ -0,0 +1,89 @@
import { browser } from '$app/environment';
import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism';
const STORAGE_KEY = 'pp_session';
interface PersistedSession {
id: string;
accessToken: string;
previewToken: string;
downloadToken: string;
user: PpUser;
}
function loadInitial(): PersistedSession | null {
if (!browser) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as PersistedSession;
} catch {
return null;
}
}
/**
* Single-source session state for the Svelte client. Components import the
* `session` object and read its reactive fields; mutations go through the
* helpers below. State is mirrored to localStorage so a hard refresh keeps
* the user signed in.
*/
const initial = loadInitial();
export const session = $state<{
id: string | null;
accessToken: string | null;
previewToken: string | null;
downloadToken: string | null;
user: PpUser | null;
}>({
id: initial?.id ?? null,
accessToken: initial?.accessToken ?? null,
previewToken: initial?.previewToken ?? null,
downloadToken: initial?.downloadToken ?? null,
user: initial?.user ?? null
});
export function isAuthenticated(): boolean {
return Boolean(session.accessToken);
}
export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void {
session.id = resp.id;
session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
session.downloadToken = (cfg ?? resp.config)?.downloadToken ?? '';
session.user = resp.user;
persist();
}
export function clearSession(): void {
session.id = null;
session.accessToken = null;
session.previewToken = null;
session.downloadToken = null;
session.user = null;
if (browser) localStorage.removeItem(STORAGE_KEY);
}
function persist(): void {
if (!browser || !session.accessToken) return;
const payload: PersistedSession = {
id: session.id ?? '',
accessToken: session.accessToken,
previewToken: session.previewToken ?? '',
downloadToken: session.downloadToken ?? '',
user: session.user!
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
/**
* Build a thumbnail URL for a photo. PhotoPrism's thumb endpoint is
* /api/v1/t/:hash/:token/:size — the token is the per-session
* previewToken, which the session response provides on login.
*/
export function thumbUrl(hash: string, size = 'tile_500'): string {
if (!session.previewToken) return '';
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
}

View File

@@ -0,0 +1,44 @@
/**
* LIFO stack of undoable actions. Each push registers an inverse callback;
* pop runs the most recent inverse and removes it from the stack. Keep the
* UI feedback honest: undo entries are best-effort and exact restoration
* isn't always possible (e.g. when the inverse depends on server state that
* has since changed).
*/
export interface UndoEntry {
id: string;
label: string;
pushedAt: number;
undo(): Promise<void> | void;
}
const MAX_ENTRIES = 25;
let counter = 0;
export const undoStack = $state<{ entries: UndoEntry[] }>({ entries: [] });
export function push(label: string, undo: UndoEntry['undo']): UndoEntry {
const entry: UndoEntry = {
id: `u-${++counter}`,
label,
pushedAt: Date.now(),
undo
};
undoStack.entries.push(entry);
if (undoStack.entries.length > MAX_ENTRIES) {
undoStack.entries.shift();
}
return entry;
}
export async function popAndRun(): Promise<UndoEntry | null> {
const entry = undoStack.entries.pop();
if (!entry) return null;
await entry.undo();
return entry;
}
export function clear(): void {
undoStack.entries.length = 0;
}

View File

@@ -0,0 +1,123 @@
import { browser } from '$app/environment';
/**
* View-level UI preferences. Persisted to localStorage so collapse state,
* thumb size, etc. survive a refresh. Same module is reused by the
* timeline page and the preview overlay so the sidebar toggle stays in
* sync across views (matches mule-image's "intelligent preview recall").
*/
const STORAGE_KEY = 'mule_view';
/**
* Mirrors mule-image's mule-image-viewSettingsStore thumbnail presets so the
* UX scales the same way: five steps with `M` as the default. Labels are
* cosmetic; the numbers feed the `minmax(<size>px, 1fr)` grid template.
*/
export const THUMBNAIL_SIZE_PRESETS = [96, 128, 160, 208, 272] as const;
export type ThumbnailSize = (typeof THUMBNAIL_SIZE_PRESETS)[number];
export const THUMBNAIL_SIZE_LABELS = ['XS', 'S', 'M', 'L', 'XL'] as const;
export const DEFAULT_THUMBNAIL_SIZE: ThumbnailSize = 160;
interface Persisted {
rightSidebarCollapsed?: boolean;
leftSidebarCollapsed?: boolean;
thumbnailSize?: ThumbnailSize;
leftSidebarWidth?: number;
rightSidebarWidth?: number;
}
export const MIN_LEFT_WIDTH = 180;
export const MAX_LEFT_WIDTH = 480;
export const DEFAULT_LEFT_WIDTH = 224;
export const MIN_RIGHT_WIDTH = 220;
export const MAX_RIGHT_WIDTH = 480;
export const DEFAULT_RIGHT_WIDTH = 280;
function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n));
}
function isThumbnailSize(n: unknown): n is ThumbnailSize {
return (
typeof n === 'number' &&
(THUMBNAIL_SIZE_PRESETS as readonly number[]).includes(n)
);
}
function loadInitial(): Persisted {
if (!browser) return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Persisted) : {};
} catch {
return {};
}
}
const initial = loadInitial();
export const view = $state<{
rightSidebarCollapsed: boolean;
leftSidebarCollapsed: boolean;
thumbnailSize: ThumbnailSize;
leftSidebarWidth: number;
rightSidebarWidth: number;
}>({
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
leftSidebarCollapsed: initial.leftSidebarCollapsed ?? false,
thumbnailSize: isThumbnailSize(initial.thumbnailSize)
? initial.thumbnailSize
: DEFAULT_THUMBNAIL_SIZE,
leftSidebarWidth: clamp(
typeof initial.leftSidebarWidth === 'number' ? initial.leftSidebarWidth : DEFAULT_LEFT_WIDTH,
MIN_LEFT_WIDTH,
MAX_LEFT_WIDTH
),
rightSidebarWidth: clamp(
typeof initial.rightSidebarWidth === 'number' ? initial.rightSidebarWidth : DEFAULT_RIGHT_WIDTH,
MIN_RIGHT_WIDTH,
MAX_RIGHT_WIDTH
)
});
function persist(): void {
if (!browser) return;
const payload: Persisted = {
rightSidebarCollapsed: view.rightSidebarCollapsed,
leftSidebarCollapsed: view.leftSidebarCollapsed,
thumbnailSize: view.thumbnailSize,
leftSidebarWidth: view.leftSidebarWidth,
rightSidebarWidth: view.rightSidebarWidth
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
export function setLeftSidebarWidth(px: number): void {
view.leftSidebarWidth = clamp(Math.round(px), MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
persist();
}
export function setRightSidebarWidth(px: number): void {
view.rightSidebarWidth = clamp(Math.round(px), MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
persist();
}
export function setThumbnailSize(size: ThumbnailSize): void {
view.thumbnailSize = size;
persist();
}
export function toggleRightSidebar(): void {
view.rightSidebarCollapsed = !view.rightSidebarCollapsed;
persist();
}
export function setRightSidebarCollapsed(collapsed: boolean): void {
view.rightSidebarCollapsed = collapsed;
persist();
}
export function toggleLeftSidebar(): void {
view.leftSidebarCollapsed = !view.leftSidebarCollapsed;
persist();
}