# Architecture ## Monorepo ``` packages/photo-sdk/ # the product (React + TS, self-contained CSS, no ML deps) src/ components/ # PhotoGallery, AppShell, Sidebar, TopToolbar, views/, editor/, Lightbox, # MediaGrid, PhotoTile, ContextMenu, Modal, SelectionBar, AIAnalyzer, AnnotationLayer store/ # Zustand store (createGalleryStore) + selectors + React context adapters/ # StorageAdapter interface + localStorage default (Supabase planned) ai/ # AIProvider interface (detect/faces/ocr/caption/embed/generativeEdit) + helpers lib/ # classify, smartAlbums, grouping, edits, media (import/EXIF), format, download icons/ # original SF-style SVG set styles/sdk.css # design tokens (light/dark/semi-dark) + components, prefixed .apg- apps/web/ # Next.js demo src/app/ # landing, /gallery, /api/ai/edit (Gemini proxy), layout, middleware (nonce CSP) src/lib/ai/ # tensorflowProvider (coco-ssd), createDemoAIProvider (+ Gemini generativeEdit) src/lib/seed.ts # demo dataset (Picsum images + local video + synthetic metadata) ``` ## Data flow `` → creates a per-instance Zustand store → `GalleryProvider` (context) → `AppShell` → Sidebar + Toolbar + `ViewRouter`. The store holds media/albums/people/selection/view/theme/AI status. A pluggable `StorageAdapter` loads/saves state (debounced). Inputs are normalized + sanitized through `normalizeMediaItem` (URL-scheme allow-list, string coercion) on both the `photos` prop and adapter load. ## Adapters (storage) — all optional `StorageAdapter = { name, load(), save(state), putBlob?, clear?, applyChanges?, putMedia? }`. - Default: `createLocalStorageAdapter()` (metadata in localStorage, blobs in IndexedDB). - `applyChanges?(changes: StateChanges)` — **incremental persistence**. When present the store calls it *instead of* `save()`, sending only entities whose object reference changed since the last successful persist (`upsert*` / `remove*` per collection, plus `labelAliases` / `deletedLabels` on a shallow diff). The store keeps per-instance id→reference snapshots seeded from `load()`, serializes overlapping writes, and rolls the snapshot back on rejection so the next persist retries the same diff. - `putMedia?(id, blob, { name, mime }): Promise<{ ref, url }>` — **durable bytes**, preferred over `putBlob`. `url` goes to `MediaItem.src` (may be a short-lived signed URL); `ref` goes to `MediaItem.storageRef` and is what survives a reload. - Planned: Supabase (Postgres + Storage), S3/R2/GCS/Azure/MinIO, REST/GraphQL. Swap via the `adapter` prop. ## Embedding in a host app The gallery is standalone-by-default but fully suppressible. `GalleryConfig` carries `embedded`, `chrome { titlebar, sidebar, toolbar, themeSwitcher }` (`DEFAULT_CHROME`), `keyboardShortcuts`, `currentUser: GalleryUser` and `shareBaseUrl`; `` exposes each as a prop and pushes the recomputed config back into the store via `setConfig` on every change, so theme / tokens / identity are live rather than mount-time-only. `AppShell` skips `` / `` per `chrome`, and `useKeyboardShortcuts(enabled)` takes a flag (never a conditional hook call). `embedded` adds `.apg--embedded` (`height: 100%`, `cursor: pointer` on controls); overlays stay `position: fixed`, and the Info panel honours `--apg-overlay-top`. `hiddenViews: ViewId[]` (default `[]`) drops sidebar rows and Collections cards (e.g. `screenshots`, `sys:documents`); a section label is omitted when all its rows are hidden, and `AppShell` falls back to `library` if the active view becomes hidden. With `currentUser` set, comments carry `{ authorId, author, authorAvatar }` and only the author may delete — a client mirror of the server rule. The same identity is stamped as display metadata on `MediaItem.uploadedBy` (imports + camera capture, never overwriting an existing value) and on each `MediaVersion` (`addVersion` / `restoreVersion` → `{ authorId, author, authorAvatar }`); the Info panel surfaces both. `be-crm` still owns `owner_principal_id` — `uploadedBy` is display-only. `defaultFullscreen` seeds `fullscreen` in the store (`setFullscreen` / `toggleFullscreen`; a toolbar button next to Info drives it). It adds `.apg--fullscreen` — `position: fixed; inset: 0; z-index: 1400; height: 100dvh` — so the gallery fills the viewport whatever height the host gave its container. The overlays needed no change: `position: fixed; inset: 0` resolves against the viewport, which full screen now coincides with, and `z-index: 1400` opens a stacking context their 1000–1300 layers live inside. Escape un-maximises, but only after the lightbox/editor/camera/modal/context-menu check and after clearing an object focus or selection. `lockProvider` makes the Recently Deleted lock server-backed and per-user: `status` / `set(password | null)` / `verify`. When present the store never reads or writes the `apg:lock-hash` localStorage entry — `lockConfigured` (seeded from `status()` in `init()`) is the UI's source of truth, and `lockError` separates `'wrong-password'` (verify resolved false) from `'unavailable'` (verify rejected). Without a provider the localStorage path is byte-for-byte unchanged. Full prop/token tables: [`packages/photo-sdk/README.md`](../packages/photo-sdk/README.md#embedding-in-a-host-app). ## Map view Leaflet is dynamically imported (never at module scope, so SSR is safe) and driven through refs. `clusterByLocation` groups located media by coordinate precision that rises with zoom, so pins split apart as you go in. Clicking a pin `flyTo`s it — `max(currentZoom, 14)` for a lone photo, `max(…, 12)` for a cluster, never zooming out — then pans up by half the sheet height so the pin clears the sheet that is about to cover the bottom half. Multi-photo pins carry a total-count badge and a hover mini-slider: a strip of up to 5 thumbnails plus a `+N` chip that auto-advances the preview every 900 ms while hovered. The single-photo hover tooltip (`.apg-pin__tip`) is `user-select:none` and edge-clamped — MapView measures it and sets `--tip-dx` to slide it back inside the map, adding `.apg-pin__tip--below` to flip it under the pin near the top edge — so it can't overflow or get clipped. The strip is built with plain DOM (matching the marker code, and keeping place names / filenames on `textContent`, never `innerHTML`); every interval is registered in a set that marker teardown, mode changes and unmount all drain, because a leaked one would mutate detached DOM forever. The location sheet has its own filter bar: a search field reusing `searchMedia` (so "objects" mean the same thing as in the global search), a single **date-range** control (`.apg-daterange` — a button that opens a popover of presets `All dates / Last 7 days / Last 30 days / This year` plus a custom From/To pair, all driving one `takenAt` from/to state), and a row of object-label chips built from the cluster — all AND-combined, with a live "N of M photos" count, one clear button that resets everything, and an empty state when nothing survives. ## Toolbar & Info panel The top toolbar floats as a rounded glass bar (margins all round, `--apg-radius-lg`, `--apg-shadow-md`), matching the sidebar, in embedded and full-screen alike. It stays in normal flow inside `.apg-main`, so `.apg-viewport` begins below it and scroll behaviour is unchanged — no overlap, no manual top-padding. The library-scale segmented is now a compact `.apg-scalemenu` dropdown that reuses the `.apg-menu` popover (menu role, checkmarks, arrow-key nav), which also removes the overlap it used to have with the zoom-out button. The Info panel adds: a real inline `