Files
mule-image/web/src/lib/stores/view.svelte.ts
dtoro 0d5f380948 preview: full-screen modal replaces inline split + tags route reorg
The old SplitGrid + InlinePreview pane is replaced by a full-screen
PreviewModal mounted once at the layout root. Open via Space on the
focused tile or double-click; close on Esc (or X / Space again).
Inside, PreviewPane renders the focused photo, RightSidebar carries
the metadata, BulkActionBar reuses the existing per-photo actions,
and PreviewCarousel windows ±50 thumbs around the focused index.

Selection contract matches the grid: plain click reduces, shift
extends the range, ⌘/Ctrl toggles, plain arrow drops the multi-
selection, shift-arrow extends. New clearBulkToFirst() helper makes
Esc / Clear collapse a bulk back to single-focus on its first member
before the next press fully dismisses (modal closes, grid clears
focus).

Tags route reorganised into /tags/[category]/[[value]] with its own
+layout and TagsBrowserSidebar; the old monolithic /tags/+page is
trimmed to a legacy redirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:13:20 +02:00

207 lines
6.2 KiB
TypeScript

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