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:
107
web/src/lib/stores/filters.svelte.ts
Normal file
107
web/src/lib/stores/filters.svelte.ts
Normal 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;
|
||||
}
|
||||
36
web/src/lib/stores/preview.svelte.ts
Normal file
36
web/src/lib/stores/preview.svelte.ts
Normal 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];
|
||||
}
|
||||
115
web/src/lib/stores/selection.svelte.ts
Normal file
115
web/src/lib/stores/selection.svelte.ts
Normal 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;
|
||||
}
|
||||
89
web/src/lib/stores/session.svelte.ts
Normal file
89
web/src/lib/stores/session.svelte.ts
Normal 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}`;
|
||||
}
|
||||
44
web/src/lib/stores/undo.svelte.ts
Normal file
44
web/src/lib/stores/undo.svelte.ts
Normal 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;
|
||||
}
|
||||
123
web/src/lib/stores/view.svelte.ts
Normal file
123
web/src/lib/stores/view.svelte.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user