- Updated toolbar styles for improved layout and appearance, including padding and margin adjustments. - Introduced new styles for scale menu and compact dropdowns. - Enhanced map pin tooltips with better positioning and hover effects. - Added new styles for map sheet filters, including search and date range controls. - Improved editor styles for a more cohesive look and feel, including transport controls for video editing. - Expanded GeoLocation interface to include additional address fields and formatted address. - Updated MediaItem interface to include storage reference and uploadedBy fields. - Enhanced MediaComment interface with author details for better user identification. - Introduced GalleryUser interface to represent signed-in users in the host app.
10 KiB
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
<PhotoGallery> → 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 ofsave(), sending only entities whose object reference changed since the last successful persist (upsert*/remove*per collection, pluslabelAliases/deletedLabelson a shallow diff). The store keeps per-instance id→reference snapshots seeded fromload(), 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 overputBlob.urlgoes toMediaItem.src(may be a short-lived signed URL);refgoes toMediaItem.storageRefand is what survives a reload.- Planned: Supabase (Postgres + Storage), S3/R2/GCS/Azure/MinIO, REST/GraphQL. Swap via the
adapterprop.
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; <PhotoGallery> 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 <Sidebar> / <TopToolbar>
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.
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 flyTos 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 <video> preview (.apg-info__thumb--video) at the top for video items
(not a frozen poster <img>); an "Uploaded by" identity block and per-version author; comments rendered as
cards (.apg-comment) with a divider before the compose form; click-to-edit caption
and multi-line note (saved on blur/Enter via updateMedia, and note is in the search haystack);
and full-address reverse geocoding. On opening a located item whose location.formatted is unset, it
fetches OpenStreetMap Nominatim once (addressdetails=1), parses road/…/countryCode/formatted into
GeoLocation, and persists via updateMedia (survives reload, never re-fetched — an in-flight/failed
id guard prevents loops and double-fetches). A hardened host CSP must allow nominatim.openstreetmap.org
in connect-src.
AI providers — all optional, free unless noted
AIProvider methods (each optional): detectObjects, detectFaces, caption, ocr, embedImage,
embedText, generativeEdit. The SDK ships no ML deps; providers are injected by the host:
tensorflowProvider(COCO-SSD) — free, in-browser object detection.createDemoAIProvider— combines detection + GeminigenerativeEdit(proxied through/api/ai/edit).- Planned: face-api.js (faces), tesseract.js (OCR), transformers.js / Hugging Face (semantic search, captions).
AIAnalyzer(headless) runs detection across the library and writesobjects/objectLabelsback.
Theming
CSS variables on .apg[data-theme]. Themes: light, dark, semi-dark (glass sidebar + light content).
Customizable via props → CSS vars: --apg-accent, --apg-radius*, plus the full ThemeTokens map
(backgrounds, cards, toolbar/menu glass, separators, hover/active, text tiers, glass border, font,
shadows, tile-favourite, overlay/editor backgrounds, segmented pill, sidebar width, toolbar height) —
xLight/xDark for theme-paired values, bare names for theme-independent ones. Applied as inline CSS
vars on the root, filtered through a url()/expression()/javascript:/<>{} guard. Token → variable
table: packages/photo-sdk/README.md.
All feature toggles via features.
Security
Per-request nonce CSP in apps/web/src/middleware.ts (script-src 'self' 'nonce-…' 'strict-dynamic',
no unsafe-inline), plus X-Frame-Options/nosniff/Referrer/Permissions-Policy. API keys are server-side
only (read in route handlers; never NEXT_PUBLIC_). Imports are type/scheme validated.
See ROADMAP.md for status of every capability.