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:
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