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,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();
}