Files
lynkeduppro-crm/vendor/photo-gallery-sdk/src/components/PhotoGallery.tsx
T

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>
);
}