# @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 `