'use client'; import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'; import { createLocalStorageAdapter } from '../adapters/localStorage'; import type { StorageAdapter } from '../adapters/types'; import type { AIProvider } from '../ai/types'; import { normalizeMediaItem, type MediaInput } from '../lib/media'; import { GalleryStoreContext, useGallery, useGalleryStoreApi } from '../store/context'; import { createGalleryStore, DEFAULT_CHROME, DEFAULT_FEATURES, type GalleryConfig, type GalleryFeatures, type GalleryStore, type ThemeTokens, } from '../store/store'; import type { Album, GalleryUser, MediaItem, ThemePreference, ViewId } from '../types'; import { AIAnalyzer } from './AIAnalyzer'; import { SemanticSearch } from './SemanticSearch'; import { AIProviderContext } from './aiContext'; import { AppShell } from './AppShell'; import { Camera } from './Camera'; import { ContextMenuHost } from './ContextMenu'; import { PhotoEditor } from './editor/PhotoEditor'; import { VideoEditor } from './editor/VideoEditor'; import { InfoPanel } from './InfoPanel'; import { Lightbox } from './Lightbox'; import { ModalHost } from './Modal'; export interface PhotoGalleryProps { /** Initial media. Accepts full MediaItems or loose `{ src, name?, ... }` inputs. */ photos?: Array; /** Initial user albums. */ albums?: Album[]; /** Storage backend. Defaults to a zero-config localStorage adapter. */ adapter?: StorageAdapter; /** AI provider for object/face/caption/search. Pass `false` to disable. */ ai?: AIProvider | boolean; theme?: ThemePreference; accentColor?: string; /** Base corner radius in px (default 10). Drives all rounded UI. */ borderRadius?: number; /** Per-theme color / gradient / radius overrides (mapped to CSS variables). */ themeTokens?: ThemeTokens; features?: Partial; /** Render the macOS-style traffic-light title bar. Maps onto `chrome.titlebar`. */ showWindowChrome?: boolean; title?: string; /** * The host app's signed-in user. When set, comments are stamped with this * identity (avatar + name + id) instead of a free-text author field, and only * the user's own comments show a delete affordance. */ currentUser?: GalleryUser; /** Base URL for generated share links (default `${location.origin}/gallery`). */ shareBaseUrl?: string; /** Suppress pieces of the gallery's own chrome that the host already provides. */ chrome?: Partial; /** * Sidebar rows + Collections sections to hide (by ViewId). * e.g. `['screenshots', 'sys:documents']`. Default `[]` (nothing hidden). */ hiddenViews?: ViewId[]; /** Bind global keyboard shortcuts to `window` (default true). */ keyboardShortcuts?: boolean; /** * Embedded mode: the gallery fills its host container (height 100%) instead of * assuming a full-viewport parent, and interactive elements use `cursor: pointer`. */ embedded?: boolean; /** * Server-backed, per-user lock for the Recently Deleted view. When supplied the * SDK uses this INSTEAD of its device-local localStorage hash. */ lockProvider?: { status(): Promise<{ hasPassword: boolean }>; set(password: string | null): Promise; // null clears it verify(password: string): Promise; }; /** Start the gallery maximised (default false). */ defaultFullscreen?: boolean; className?: string; style?: CSSProperties; onReady?: () => void; } export function PhotoGallery(props: PhotoGalleryProps) { const { photos, albums, adapter, ai, theme = 'system', accentColor, borderRadius, themeTokens, features, showWindowChrome = false, title = 'Photos', currentUser, shareBaseUrl, chrome, hiddenViews, keyboardShortcuts = true, embedded = false, lockProvider, defaultFullscreen = false, className, style, onReady, } = props; // Resolve adapter / AI once. const adapterRef = useRef(null); if (!adapterRef.current) { adapterRef.current = adapter ?? createLocalStorageAdapter(); } const aiProvider: AIProvider | null = useMemo( () => (ai && typeof ai === 'object' ? ai : null), [ai], ); // Destructure chrome to primitives so an inline `chrome={{…}}` literal doesn't // recompute the config on every host render. `showWindowChrome` is the legacy // spelling of `chrome.titlebar`; an explicit `chrome.titlebar` wins. const chromeTitlebar = chrome?.titlebar ?? showWindowChrome; const chromeSidebar = chrome?.sidebar ?? DEFAULT_CHROME.sidebar; const chromeToolbar = chrome?.toolbar ?? DEFAULT_CHROME.toolbar; const chromeThemeSwitcher = chrome?.themeSwitcher ?? DEFAULT_CHROME.themeSwitcher; const userId = currentUser?.id; const userName = currentUser?.name; const userEmail = currentUser?.email; const userAvatar = currentUser?.avatarUrl; // Stable key so an inline `hiddenViews={['screenshots']}` literal doesn't // recompute the config on every host render. const hiddenViewsKey = (hiddenViews ?? []).join(','); const config: GalleryConfig = useMemo( () => ({ features: { ...DEFAULT_FEATURES, ...features }, accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff', borderRadius: borderRadius ?? 10, showWindowChrome: chromeTitlebar, title, themeTokens, currentUser: userId !== undefined ? { id: userId, name: userName ?? '', email: userEmail, avatarUrl: userAvatar } : undefined, shareBaseUrl, chrome: { titlebar: chromeTitlebar, sidebar: chromeSidebar, toolbar: chromeToolbar, themeSwitcher: chromeThemeSwitcher, }, hiddenViews: hiddenViewsKey ? (hiddenViewsKey.split(',') as ViewId[]) : [], keyboardShortcuts, embedded, lockProvider, defaultFullscreen, }), [ features, accentColor, borderRadius, themeTokens, title, userId, userName, userEmail, userAvatar, shareBaseUrl, chromeTitlebar, chromeSidebar, chromeToolbar, chromeThemeSwitcher, hiddenViewsKey, keyboardShortcuts, embedded, lockProvider, defaultFullscreen, ], ); // Create the store exactly once for this gallery instance. const [store] = useState(() => createGalleryStore({ config, // Normalize + sanitize every input (loose or full MediaItem) through one path. initialMedia: (photos ?? []) .map((p) => normalizeMediaItem(p)) .filter((m): m is MediaItem => m !== null), initialAlbums: albums ?? [], }), ); // Apply the initial theme preference into the store. useEffect(() => { store.getState().setTheme(theme); }, [store, theme]); // Keep the live config in sync with the props. The store is created once, so // without this a host changing theme tokens / user / chrome would have no effect. useEffect(() => { store.getState().setConfig(config); }, [store, config]); return ( ); } interface GalleryRootProps { adapter: StorageAdapter; ai: AIProvider | null; className?: string; style?: CSSProperties; onReady?: () => void; } function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProps) { const api = useGalleryStoreApi(); const ready = useGallery((s) => s.ready); const theme = useGallery((s) => s.theme); const resolvedTheme = useGallery((s) => s.resolvedTheme); const accent = useGallery((s) => s.config.accentColor); const radius = useGallery((s) => s.config.borderRadius); const tokens = useGallery((s) => s.config.themeTokens); const showChrome = useGallery((s) => s.config.chrome.titlebar); const embedded = useGallery((s) => s.config.embedded); const fullscreen = useGallery((s) => s.fullscreen); const aiEnabled = useGallery((s) => s.config.features.ai); const cameraEnabled = useGallery((s) => s.config.features.camera); // Initialize (load persisted state) EXACTLY once — the ref guard prevents a // double init() (React Strict Mode / remounts) from re-seeding an empty backend. const initedRef = useRef(false); useEffect(() => { if (initedRef.current) return; initedRef.current = true; void api.getState().init(adapter, ai); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (ready) onReady?.(); }, [ready, onReady]); // Resolve "system" theme and react to OS changes. useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) { api.getState().setResolvedTheme(theme === 'system' ? 'light' : theme); return; } if (theme !== 'system') { api.getState().setResolvedTheme(theme); return; } const mq = window.matchMedia('(prefers-color-scheme: dark)'); const apply = () => api.getState().setResolvedTheme(mq.matches ? 'dark' : 'light'); apply(); mq.addEventListener('change', apply); return () => mq.removeEventListener('change', apply); }, [api, theme]); // Map optional theme tokens → CSS variables for the active theme. Dark values // apply in dark mode and to the semi-dark sidebar; light values elsewhere. const dark = resolvedTheme === 'dark'; const tokenVars: Record = {}; if (tokens) { // Colors/gradients only — reject url()/expression()/JS or CSS breakout chars // (defense-in-depth; tokens normally come from build-time env, not user input). const CSS_UNSAFE = /(url\(|expression\(|javascript:|[<>{}])/i; const set = (v: string | undefined, name: string) => { if (v && !CSS_UNSAFE.test(v)) tokenVars[name] = v; }; /** Pick the light/dark member of a token pair for the active theme. */ const pick = (light: string | undefined, darkValue: string | undefined) => dark ? darkValue : light; const px = (v: number | undefined, name: string) => { if (typeof v === 'number' && Number.isFinite(v)) tokenVars[name] = `${v}px`; }; const bg = pick(tokens.bgLight, tokens.bgDark); set(bg, '--apg-bg'); set(bg, '--apg-bg-content'); set(pick(tokens.elevatedLight, tokens.elevatedDark), '--apg-bg-elevated'); // In semi-dark the sidebar is always the dark glass value. set( resolvedTheme === 'semi-dark' ? tokens.sidebarBgDark : pick(tokens.sidebarBgLight, tokens.sidebarBgDark), '--apg-sidebar-bg', ); set(pick(tokens.textLight, tokens.textDark), '--apg-text'); px(tokens.sidebarRadius, '--apg-sidebar-radius'); // ---- extended token map ---- set(pick(tokens.accentStrongLight, tokens.accentStrongDark), '--apg-accent-strong'); set(tokens.accentContrast, '--apg-accent-contrast'); set(pick(tokens.dangerLight, tokens.dangerDark), '--apg-danger'); set(pick(tokens.cardLight, tokens.cardDark), '--apg-card'); set(pick(tokens.cardHoverLight, tokens.cardHoverDark), '--apg-card-hover'); set(pick(tokens.toolbarBgLight, tokens.toolbarBgDark), '--apg-toolbar-bg'); set(pick(tokens.menuBgLight, tokens.menuBgDark), '--apg-menu-bg'); set(pick(tokens.separatorLight, tokens.separatorDark), '--apg-separator'); set( pick(tokens.separatorStrongLight, tokens.separatorStrongDark), '--apg-separator-strong', ); set(pick(tokens.hoverLight, tokens.hoverDark), '--apg-hover'); set(pick(tokens.activeLight, tokens.activeDark), '--apg-active'); set( pick(tokens.sidebarSelectedLight, tokens.sidebarSelectedDark), '--apg-sidebar-selected', ); set(pick(tokens.textSecondaryLight, tokens.textSecondaryDark), '--apg-text-secondary'); set(pick(tokens.textTertiaryLight, tokens.textTertiaryDark), '--apg-text-tertiary'); set(pick(tokens.glassBorderLight, tokens.glassBorderDark), '--apg-glass-border'); set(tokens.fontFamily, '--apg-font'); px(tokens.radiusMenu, '--apg-radius-menu'); set(tokens.shadowSm, '--apg-shadow-sm'); set(pick(tokens.shadowMdLight, tokens.shadowMdDark), '--apg-shadow-md'); set(pick(tokens.shadowLgLight, tokens.shadowLgDark), '--apg-shadow-lg'); set(tokens.tileFav, '--apg-tile-fav'); set(tokens.overlayBg, '--apg-overlay-bg'); set(tokens.editorBg, '--apg-editor-bg'); set(tokens.segmentedActive, '--apg-segmented-active'); px(tokens.sidebarWidth, '--apg-sidebar-w'); px(tokens.toolbarHeight, '--apg-toolbar-h'); } // Where full-screen overlays (the Info panel) start. A host with its own header // bar can push this down; default matches the standalone toolbar offset. const overlayTop = tokens?.toolbarHeight ? `${tokens.toolbarHeight + 12}px` : '64px'; const rootStyle: CSSProperties = { ['--apg-accent' as string]: accent, ['--apg-radius' as string]: `${radius}px`, ['--apg-radius-sm' as string]: `${Math.max(2, Math.round(radius * 0.6))}px`, ['--apg-radius-lg' as string]: `${Math.round(radius * 1.4)}px`, ['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`, ['--apg-overlay-top' as string]: overlayTop, ...tokenVars, ...style, }; return (
{showChrome ? : null}
{cameraEnabled ? : null} {ai && aiEnabled ? : null} {ai && aiEnabled ? : null}
); } function WindowChrome() { const title = useGallery((s) => s.config.title); return (
{title}
); }