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(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; tagsBrowserWidth?: number; tagsBrowserCollapsed?: boolean; /** * Per-section expanded state for the right-sidebar metadata panel * (GPS, Credits, File). Keyed by section id; missing entries use a * static default supplied by the component, so the user's chosen * collapse state stays put as they navigate between photos. */ metadataSections?: Record; } 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; export const MIN_TAGS_BROWSER_WIDTH = 180; export const MAX_TAGS_BROWSER_WIDTH = 480; export const DEFAULT_TAGS_BROWSER_WIDTH = 240; 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; tagsBrowserWidth: number; tagsBrowserCollapsed: boolean; /** * Ephemeral: true while the full-screen preview modal is open. Not * persisted — a refresh always returns to the grid. */ previewOpen: boolean; metadataSections: Record; }>({ 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 ), tagsBrowserWidth: clamp( typeof initial.tagsBrowserWidth === 'number' ? initial.tagsBrowserWidth : DEFAULT_TAGS_BROWSER_WIDTH, MIN_TAGS_BROWSER_WIDTH, MAX_TAGS_BROWSER_WIDTH ), tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false, previewOpen: false, metadataSections: initial.metadataSections && typeof initial.metadataSections === 'object' ? { ...initial.metadataSections } : {} }); function persist(): void { if (!browser) return; const payload: Persisted = { rightSidebarCollapsed: view.rightSidebarCollapsed, leftSidebarCollapsed: view.leftSidebarCollapsed, thumbnailSize: view.thumbnailSize, leftSidebarWidth: view.leftSidebarWidth, rightSidebarWidth: view.rightSidebarWidth, tagsBrowserWidth: view.tagsBrowserWidth, tagsBrowserCollapsed: view.tagsBrowserCollapsed, metadataSections: view.metadataSections }; 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 setTagsBrowserWidth(px: number): void { view.tagsBrowserWidth = clamp( Math.round(px), MIN_TAGS_BROWSER_WIDTH, MAX_TAGS_BROWSER_WIDTH ); persist(); } export function toggleTagsBrowser(): void { view.tagsBrowserCollapsed = !view.tagsBrowserCollapsed; persist(); } export function openPreview(): void { view.previewOpen = true; } export function closePreview(): void { view.previewOpen = false; } export function togglePreview(): void { view.previewOpen = !view.previewOpen; } 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(); } /** * Read the persisted expanded state for a right-sidebar metadata * section, falling back to `defaultOpen` when the user has never * toggled it. `defaultOpen` should be a *static* value — using a * per-photo data-driven default would change between photos, fire a * programmatic `toggle` event, and silently overwrite the user's * preference. */ export function getMetadataSectionOpen(id: string, defaultOpen: boolean): boolean { const v = view.metadataSections[id]; return typeof v === 'boolean' ? v : defaultOpen; } /** Record the user's explicit collapse/expand choice for a metadata * section. Persists immediately so a reload keeps the layout the * user picked. */ export function setMetadataSection(id: string, open: boolean): void { view.metadataSections[id] = open; persist(); }