404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
'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<MediaItem | MediaInput>;
|
|
/** 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<GalleryFeatures>;
|
|
/** 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<GalleryConfig['chrome']>;
|
|
/**
|
|
* 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<void>; // null clears it
|
|
verify(password: string): Promise<boolean>;
|
|
};
|
|
/** 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<StorageAdapter | null>(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<GalleryStore>(() =>
|
|
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 (
|
|
<GalleryStoreContext.Provider value={store}>
|
|
<GalleryRoot
|
|
adapter={adapterRef.current}
|
|
ai={aiProvider}
|
|
className={className}
|
|
style={style}
|
|
onReady={onReady}
|
|
/>
|
|
</GalleryStoreContext.Provider>
|
|
);
|
|
}
|
|
|
|
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<string, string> = {};
|
|
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 (
|
|
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
|
|
<div
|
|
className={[
|
|
'apg',
|
|
embedded ? 'apg--embedded' : '',
|
|
fullscreen ? 'apg--fullscreen' : '',
|
|
className,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
data-theme={resolvedTheme}
|
|
style={rootStyle}
|
|
>
|
|
{showChrome ? <WindowChrome /> : null}
|
|
<div className="apg__body">
|
|
<AppShell />
|
|
</div>
|
|
<Lightbox />
|
|
<PhotoEditor />
|
|
<VideoEditor />
|
|
<InfoPanel />
|
|
{cameraEnabled ? <Camera /> : null}
|
|
<ModalHost />
|
|
<ContextMenuHost />
|
|
{ai && aiEnabled ? <AIAnalyzer provider={ai} /> : null}
|
|
{ai && aiEnabled ? <SemanticSearch provider={ai} /> : null}
|
|
</div>
|
|
</AIProviderContext.Provider>
|
|
);
|
|
}
|
|
|
|
function WindowChrome() {
|
|
const title = useGallery((s) => s.config.title);
|
|
return (
|
|
<div className="apg-titlebar">
|
|
<div className="apg-traffic">
|
|
<span className="apg-traffic__dot apg-traffic__dot--red" />
|
|
<span className="apg-traffic__dot apg-traffic__dot--yellow" />
|
|
<span className="apg-traffic__dot apg-traffic__dot--green" />
|
|
</div>
|
|
<div className="apg-titlebar__title">{title}</div>
|
|
</div>
|
|
);
|
|
}
|