# @photo-gallery/sdk A reusable, macOS Photos-style photo gallery for React / Next.js — light & dark, fully responsive, pluggable storage and AI. One component, no required CSS framework. ```tsx import { PhotoGallery } from '@photo-gallery/sdk'; import '@photo-gallery/sdk/styles.css'; export default () => ; ``` See the [repository README](../../README.md) for full documentation, props, adapters and the AI-provider interface. ## Embedding in a host app By default `` behaves like a standalone app: it owns the sidebar, the toolbar, the theme switcher and the global keyboard shortcuts, and it assumes a full-viewport parent. All of that is opt-out, so the gallery can be dropped into an existing product's shell instead. ```tsx
``` ### New props | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `embedded` | `boolean` | `false` | Lays out at `height: 100%` inside the host container instead of assuming a full-viewport parent, and switches interactive elements to `cursor: pointer`. | | `currentUser` | `GalleryUser` | – | The host's signed-in user (`{ id, name, email?, avatarUrl? }`). Comments are stamped with this identity; the free-text author field disappears. | | `shareBaseUrl` | `string` | `${location.origin}/gallery` | Base URL for generated share links (`?shared=` is appended). | | `chrome` | `Partial` | `DEFAULT_CHROME` | Suppress chrome the host already provides: `{ titlebar, sidebar, toolbar, themeSwitcher }`. | | `hiddenViews` | `ViewId[]` | `[]` | Sidebar rows + Collections cards to hide, e.g. `['screenshots', 'sys:documents']`. A section label drops when all its rows are hidden; if the active view becomes hidden the gallery falls back to `library`. | | `keyboardShortcuts` | `boolean` | `true` | Bind global shortcuts (⌘A, Delete, F, Esc, Enter) to `window`. | | `defaultFullscreen` | `boolean` | `false` | Start maximised over the host page. | | `lockProvider` | `LockProvider` | – | Server-backed, per-user lock for Recently Deleted. Replaces the device-local localStorage hash entirely. | `showWindowChrome` still works and maps onto `chrome.titlebar`; an explicit `chrome.titlebar` wins. `DEFAULT_CHROME` is exported (`{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: true }`). Turning `chrome.themeSwitcher` off removes the Appearance items from the "More" menu, so a host that owns light/dark cannot be overridden from inside the gallery. The config is now **live**: changing `theme`, `themeTokens`, `currentUser` or `chrome` re-applies without remounting the gallery. ### Full screen / maximise The toolbar carries a maximise/restore button (right-hand group, next to Info). Turning it on adds `apg--fullscreen` to the root element, which goes `position: fixed; inset: 0; z-index: 1400; height: 100dvh; border-radius: 0` — so the gallery fills the viewport no matter what height the host container gave it, and sits above the host's own chrome. Escape leaves full screen, but only once nothing else owns Escape: the lightbox, the photo/video editors, the camera, modals and context menus all get it first, and it still clears an object focus or a selection before it un-maximises. Escape is part of the shortcut set, so `keyboardShortcuts={false}` opts out of that too — the button always works. The SDK's own overlays needed no change. They are `position: fixed; inset: 0`, which resolves against the viewport — and in full screen the gallery covers exactly the viewport, so they still land right. `z-index: 1400` opens a stacking context, so their existing 1000–1300 z-indexes now stack *inside* it: above the gallery's content and, through 1400, above the host's chrome. Headless control lives on the store: `fullscreen`, `setFullscreen(next)`, `toggleFullscreen()`. `defaultFullscreen` only seeds the initial value; the user's toggle owns it afterwards. ### Server-backed Recently Deleted lock By default the Recently Deleted lock is a password hash in `localStorage` — device-local, and invisible to your backend. Pass a `lockProvider` and the SDK delegates every lock operation to it instead, so the lock belongs to the *user* and follows them across devices. The localStorage path is untouched when no provider is given. ```tsx call('crm.gallery.lock.status', {}), set: async (password) => { await call('crm.gallery.lock.set', { password }); }, verify: async (password) => (await call('crm.gallery.lock.verify', { password })).ok, }} /> ``` ```ts interface LockProvider { status(): Promise<{ hasPassword: boolean }>; set(password: string | null): Promise; // null clears it verify(password: string): Promise; } ``` Drive your UI off `lockConfigured: boolean` (refreshed from `status()` during `init()`, and again if the provider arrives late), not `lock.hash` — that field is meaningless on the provider path. Failures are distinguished: a `verify()` that *resolves false* sets `lockError: 'wrong-password'` ("Incorrect password."), while one that *rejects* sets `lockError: 'unavailable'` ("Couldn't check the password. Please try again.") so the user retries instead of doubting what they typed. `clearLockError()` resets it; `refreshLockStatus()` re-reads `status()` on demand. ### Theme tokens → CSS variables Every value is optional. Tokens ending in `Light`/`Dark` are theme-paired (the matching one is applied for the resolved theme); bare names are theme-independent. Numbers are emitted as `px`. Values are rejected if they contain `url(`, `expression(`, `javascript:` or `<>{}`. | Token | CSS variable | Notes | | --- | --- | --- | | `accent` | `--apg-accent` | Also overrides the `accentColor` prop. | | `accentStrongLight` / `accentStrongDark` | `--apg-accent-strong` | Accent behind white text (AA contrast). | | `accentContrast` | `--apg-accent-contrast` | Foreground on top of the accent. | | `dangerLight` / `dangerDark` | `--apg-danger` | Destructive actions. | | `bgLight` / `bgDark` | `--apg-bg` + `--apg-bg-content` | App / content background. | | `elevatedLight` / `elevatedDark` | `--apg-bg-elevated` | Raised surfaces. | | `cardLight` / `cardDark` | `--apg-card` | Collection cards. | | `cardHoverLight` / `cardHoverDark` | `--apg-card-hover` | Card hover state. | | `sidebarBgLight` / `sidebarBgDark` | `--apg-sidebar-bg` | Sidebar glass (semi-dark always uses the dark value). | | `toolbarBgLight` / `toolbarBgDark` | `--apg-toolbar-bg` | Top toolbar glass. | | `menuBgLight` / `menuBgDark` | `--apg-menu-bg` | Context menus, Info panel, popovers. | | `separatorLight` / `separatorDark` | `--apg-separator` | Hairlines. | | `separatorStrongLight` / `separatorStrongDark` | `--apg-separator-strong` | Input borders, scrollbars. | | `hoverLight` / `hoverDark` | `--apg-hover` | Row / icon-button hover wash. | | `activeLight` / `activeDark` | `--apg-active` | Pressed state. | | `sidebarSelectedLight` / `sidebarSelectedDark` | `--apg-sidebar-selected` | Selected sidebar row. | | `textLight` / `textDark` | `--apg-text` | Primary text. | | `textSecondaryLight` / `textSecondaryDark` | `--apg-text-secondary` | Labels, captions. | | `textTertiaryLight` / `textTertiaryDark` | `--apg-text-tertiary` | Hints, timestamps. | | `glassBorderLight` / `glassBorderDark` | `--apg-glass-border` | Border on glass surfaces. | | `fontFamily` | `--apg-font` | Font stack for the whole gallery. | | `sidebarRadius` | `--apg-sidebar-radius` | px. | | `radiusMenu` | `--apg-radius-menu` | px. | | `shadowSm` | `--apg-shadow-sm` | | | `shadowMdLight` / `shadowMdDark` | `--apg-shadow-md` | Menus, action bar. | | `shadowLgLight` / `shadowLgDark` | `--apg-shadow-lg` | Modals. | | `tileFav` | `--apg-tile-fav` | Favourite heart on a tile (default `#ff3b30`). | | `overlayBg` | `--apg-overlay-bg` | Lightbox backdrop (default `rgba(0,0,0,0.97)`). | | `editorBg` | `--apg-editor-bg` | Editor chrome (default `#161617`). | | `segmentedActive` | `--apg-segmented-active` | Active segmented pill. | | `sidebarWidth` | `--apg-sidebar-w` | px. | | `toolbarHeight` | `--apg-toolbar-h` | px. Also drives `--apg-overlay-top`. | The Info panel is positioned at `top: var(--apg-overlay-top, 64px)` — set `--apg-overlay-top` via the `style` prop to push it below the host's own header. Full-screen overlays (lightbox, editor, camera, modals, context menus) stay `position: fixed`, which is correct for a modal over a host app. ### Incremental storage adapters `StorageAdapter` gained two optional members. Both are additive — existing `save`/`putBlob` adapters keep working unchanged. - **`applyChanges(changes: StateChanges)`** — when present the store calls this *instead of* `save()` on every (debounced, 400 ms) change, sending only what differs from the last persisted snapshot: `{ upsertMedia?, removeMedia?, upsertAlbums?, removeAlbums?, upsertPeople?, removePeople?, labelAliases?, deletedLabels? }`. Overlapping writes are serialized, and a rejection rolls the snapshot back so the next persist retries the same diff. Only non-system albums are ever persisted. - **`putMedia(id, blob, { name, mime }): Promise`** — preferred over `putBlob`. It returns `{ ref, url }`: the store sets `item.src = url` (renderable now, may be a short-lived signed URL) and `item.storageRef = ref` (the durable reference that survives a reload). Worked example against a data-door backend: ```ts import type { StorageAdapter, StateChanges, StoredBlob } from '@photo-gallery/sdk'; export function createDataDoorAdapter(call: (a: string, p: unknown) => Promise): StorageAdapter { return { name: 'data-door', async load() { return await call('crm.gallery.state.load', {}); }, // Kept as a fallback for callers that don't use applyChanges. async save(state) { await call('crm.gallery.state.apply', { upsertMedia: state.media, upsertAlbums: state.albums, upsertPeople: state.people, labelAliases: state.labelAliases, deletedLabels: state.deletedLabels, } satisfies StateChanges); }, async applyChanges(changes: StateChanges) { await call('crm.gallery.state.apply', changes); }, async putMedia(id, blob, meta): Promise { const { ref, uploadUrl, method, headers } = await call('crm.gallery.media.presignUpload', { mediaId: id, mime: meta.mime, sizeBytes: blob.size, filename: meta.name, }); await fetch(uploadUrl, { method, headers, body: blob }); const { urls } = await call('crm.gallery.media.presignDownload', { refs: [ref] }); return { ref, url: urls[ref] }; }, }; } ``` ### Identity-aware comments With `currentUser` set, `addComment(id, text)` stamps `{ authorId, author, authorAvatar }` from the configured user, the Info panel renders the real avatar/name instead of a name input (and stops using the `apg:comment-author` localStorage key), and delete affordances appear only on comments whose `authorId` matches. `deleteComment` is a client-side no-op for someone else's comment — the backend remains the authority. Without `currentUser`, behaviour is exactly as before. ### Uploader & version identity With `currentUser` set, imports (`importFiles`) and camera captures stamp `MediaItem.uploadedBy` (a `GalleryUser`) — a re-import never overwrites an existing value. Saving an edit (`addVersion`) or restoring one (`restoreVersion`) stamps `{ authorId, author, authorAvatar }` onto the `MediaVersion`. The Info panel shows an "Uploaded by" block (avatar + name + muted email) and the author beside each version's timestamp; the Versions & Audit view shows the latest editor. All fields are optional — with no `currentUser`, nothing is stamped and the UI renders exactly as before. `be-crm` still owns `owner_principal_id`; `uploadedBy` is display identity only. ### Editable caption & notes The Info panel's caption and a new multi-line `MediaItem.note` are click-to-edit: they save on blur or Enter (⌘/Ctrl+Enter for the note) via `updateMedia`, so they persist through the adapter. `note` is folded into the search haystack, so notes are searchable. ### Info panel media & comments Video items render a real inline `