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>
207 lines
6.2 KiB
TypeScript
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();
|
|
}
|