feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user