feat: enhance styling and layout for toolbar, map components, and editor

- 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.
This commit is contained in:
2026-07-23 07:34:20 +05:30
parent bc8bf2007d
commit 7673fc63ea
35 changed files with 3130 additions and 394 deletions
+5
View File
@@ -8,6 +8,11 @@ these files for full context. Keep them updated as work proceeds.
- [requirements.md](requirements.md) — the COMPLETE requirements backlog (every request, tracked).
- Human-facing docs live in [`/docs`](../../docs/) (ROADMAP / SETUP / ARCHITECTURE) and [`/CLAUDE.md`](../../CLAUDE.md).
> **Current focus (2026-07-23):** the SDK now **embeds into a host app** (LynkedUp CRM), not only the standalone demo —
> `embedded` layout, `chrome` suppression, host identity (`currentUser`) + theming (`ThemeTokens`), server-backed
> Recently-Deleted lock, and an **incremental** `StorageAdapter` (`applyChanges` + `putMedia`). See the latest
> `status.md` session entry and `docs/ROADMAP.md` §11.
## Hard rules (do not violate)
- **Never `git commit` / `git push`** — the user does that. Never commit secrets.
- API keys live only in `apps/web/.env.local` (gitignored), read **server-side** only.
+41 -25
View File
@@ -12,34 +12,50 @@ Status: ✅ done · 🚧 in progress · ⬜ planned · 🔑 needs key/creds. Cro
- ✅ Info panel (full metadata + **mini-map**). Themes menu + `accentColor`/`borderRadius` props.
- ✅ Gemini generative editor (Remove BG/Restore/Colorize/Replace Sky/prompt) — 🔑 needs billed key (free=429).
## Now / next (in user's priority order)
1. 🚧 **Custom camera** (#1): getUserMedia preview; capture photo; record video; switch front/back; grid;
## Now / next (in user's priority order) — all ✅ done
1. **Custom camera** (#1): getUserMedia preview; capture photo; record video; switch front/back; grid;
**live annotation + measurement**; geolocation on capture (permission); auto object-detect after capture.
2. **Supabase backend + storage** (#last, 🔑): Postgres data + Supabase Storage. **No localStorage reliance**
backend is the source of truth. Ask user for creds (URL, anon key, service-role key, bucket) with steps.
3. ⬜ Face clustering → People; OCR (tesseract.js) → document search; semantic "show me beach photos"
(transformers.js / Hugging Face CLIP) — all free, in-browser.
4. ⬜ Video editor + annotations; macOS-parity player polish.
2. **Supabase backend + storage** (🔑): Postgres data + Supabase Storage adapter (run `docs/supabase-setup.sql`
once). Backend is the source of truth on load. The CRM host supplies its own **incremental** data-door adapter
(`applyChanges` + `putMedia`); localStorage remains the zero-config dev default.
3. ✅ Face clustering → People; OCR (tesseract.js) → Documents + searchable text; semantic search
(transformers.js / CLIP) — all free, in-browser.
4. ✅ Full video editor + annotations (timeline engine, no ffmpeg); ✅ macOS-parity **custom video player**.
## New requests (this session — must add)
- **Editor: straighten / tilt** (angle slider) — rotate by arbitrary degrees.
- **Crop: free-form AND fixed-ratio**when a ratio (e.g. 1:1 square) is selected, dragging/stretching the
crop box keeps that aspect ratio; also free crop.
- **Import sync of existing metadata**: if an imported/mobile image already has tags (IPTC/XMP keywords) +
location + details, read and apply them (exifr supports XMP/IPTC). Keep object detection + object albums.
- 🚧 **Web-meaningful UI** (deep pass): "All Projects" → **"All Albums"** (this is a web gallery, not iOS);
remove "Open App Store" (meaningless on web); review sidebar tabs, **corner radius**, touch-ups so everything
is meaningful for a web gallery and matches macOS more closely.
- **Search dropdown defaults**: show quick filters **Recently Viewed**, **Recently Edited** (+ Recently Shared)
when the search field is focused.
-**Info → map click opens full Map** centered on that photo's location.
-**Map parity w/ macOS**: locations + images + tags; latest image shown for a tag; **click a tag → open
that tag's images grouped by date** (date-wise). Tag = album-like view, date-grouped.
-**Local-model fallback**: if Gemini key missing/invalid, use a local model. Concretely wire
`@imgly/background-removal` (free, in-browser) for Remove Background so it always works without a key.
-**Everything free**; build our own if no free option.
## New requests (early sessionsnow all done)
- **Editor: straighten / tilt** (angle slider) — rotate by arbitrary degrees.
- **Crop: free-form AND fixed-ratio**a selected ratio (e.g. 1:1) is kept while dragging; also free crop.
-**Import sync of existing metadata**: IPTC/XMP keywords + location read on import (exifr) alongside object detection.
- **Web-meaningful UI** (deep pass): "All Projects" → **"All Albums"**; removed "App Store"; sidebar tabs / corner
radius / touch-ups reviewed for a web gallery matching macOS.
- **Search dropdown defaults**: Recently Viewed / Recently Edited / Recently Added when the search field is focused.
-**Info → map click opens full Map** centered on that photo's location.
-**Map parity w/ macOS**: click a tag → that tag's images grouped by date; thumbnail pins; location sheets.
- **Local-model fallback**: `@imgly/background-removal` (free, in-browser) wired for Remove Background — no key needed.
-**Everything free**; build our own where no free option (analysis/OCR/faces/semantic/bg-remove all run in-browser).
-**Memory in project `.claude/` folder** (this folder) — done.
- More **advanced features** (open-ended; user will add more later).
- More **advanced features** — versioning + audit log, comments, advanced video editor, sharing, password lock,
glass design polish, custom video player (open-ended; new requests continue below).
## Host-app embedding & CRM integration (2026-07-23) — done
> The SDK now embeds into a host product (LynkedUp CRM), not only the standalone demo. All of it is opt-in.
-**Embedded layout** (`embedded`) + **chrome suppression** (`chrome`, `DEFAULT_CHROME`) + **`hiddenViews`** +
**`keyboardShortcuts`** flag; live `setConfig` re-applies theme/tokens/identity/chrome without remount.
-**Host identity** (`currentUser: GalleryUser`) → identity-stamped **comments** (`authorId/author/authorAvatar`,
delete only own), **uploads** (`MediaItem.uploadedBy`, never overwritten), **versions** (author on each). Info panel
shows "Uploaded by" + per-version author. `be-crm` still owns `owner_principal_id` (display-only here).
-**`shareBaseUrl`** (host owns share-link base) + **full-screen** (`defaultFullscreen`, `.apg--fullscreen`,
`setFullscreen`/`toggleFullscreen`).
-**Full `ThemeTokens` map (52 keys)** → CSS vars (sanitized); floating glass toolbar + `.apg-scalemenu` dropdown.
-**Incremental adapter**: `applyChanges(StateChanges)` (diff-based, serialized, rollback-on-reject) +
`putMedia(id, blob, meta) → StoredBlob {ref,url}`; `MediaItem.storageRef`. Types `StateChanges`/`StoredBlob` exported.
-**Server-backed Recently-Deleted lock** (`lockProvider { status, set, verify }`) replaces the device-local hash;
`lockConfigured` = UI truth; `lockError` = wrong-password vs unavailable.
-**Editable caption + multi-line `note`** in Info (searchable); **full-address reverse geocoding** (Nominatim,
persisted); **map** fly-to / count badge / hover mini-slider / date-range location filter.
-**UI fixes**: Info-panel inline `<video>` preview; comments as cards + spacing/divider; video-editor custom
transport outside the transformed frame; map tooltip `user-select:none` + edge-clamping.
- ⬜ Still open: real multi-user auth / permissions in the host (SDK identity is display-only; backend is the authority).
## Notes
- getUserMedia needs HTTPS or localhost (dev OK). Permissions-Policy already allows camera/mic/geolocation=self.
+52 -3
View File
@@ -3,9 +3,13 @@
## Run
`pnpm install && pnpm dev` → http://localhost:3000 (landing → Open Gallery → /gallery). `pnpm build` to ship.
## Current state (2026-06-29)
Phases 13 complete & verified (see [requirements.md](requirements.md)). `pnpm build` passes; /gallery ~170 KB
(TF.js + exifr lazy). Zero console errors. Default persistence = localStorage adapter (until Supabase wired).
## Current state (2026-07-23)
All phases + both big user batches + versioning/comments + advanced video editor + RunPod AI wiring + **host-app (CRM)
embedding** complete & verified (see the session log below and [requirements.md](requirements.md)). `pnpm build` passes;
/gallery ~199 KB (TF.js / exifr / face-api / tesseract / transformers all lazy). The SDK now **embeds into a host CRM
shell** — `embedded` layout, `chrome` suppression, host identity + theming, incremental `applyChanges`/`putMedia`
adapter, server-backed Recently-Deleted lock — not only the standalone demo. Default dev persistence = localStorage
adapter; Supabase adapter included; the CRM host supplies its own data-door adapter.
## Session log
- S1: monorepo + SDK + demo; full macOS Photos UI; albums/smart-albums/recycle/import/editor/lightbox; light+dark;
@@ -344,7 +348,52 @@ Phases 13 complete & verified (see [requirements.md](requirements.md)). `pnpm
REFUTED (correctly, no change): status-URL query-string, combined-body-cap rejecting masks, replace-sky client
strength, 'mask' in op undefined-crash, mask-op has no UI button (all non-bugs / already-safe).
- S-crm-embed (2026-07-23): **Host-app (CRM) embedding + Info-panel / video-editor / map UI fixes.** The SDK became
embeddable into a host product (LynkedUp CRM) instead of only the standalone macOS-Photos demo. (Docs-audit pass this
session; the code below was landed across the CRM-integration rounds and is verified against the source.)
EMBEDDING: `PhotoGalleryProps` + `GalleryConfig` gained `embedded`, `chrome { titlebar, sidebar, toolbar,
themeSwitcher }` (+ exported `DEFAULT_CHROME`), `hiddenViews: ViewId[]`, `keyboardShortcuts`, `currentUser`,
`shareBaseUrl`, `defaultFullscreen`, `lockProvider`. `setConfig` re-applies theme/tokens/identity/chrome live (no
remount). Full `ThemeTokens` map (52 keys) → CSS vars via a `url()/expression()/javascript:/<>{}` guard. Floating
glass toolbar + `.apg-scalemenu` library-scale dropdown (replaced the Years/Months/All segmented). `.apg--fullscreen`
maximise (store `fullscreen`/`setFullscreen`/`toggleFullscreen`; toolbar button; Escape exits after
lightbox/editor/camera/modal/menu).
IDENTITY: `MediaItem.uploadedBy?: GalleryUser` (imports + camera; never overwritten on re-import); `MediaComment` and
`MediaVersion` carry `authorId/author/authorAvatar`; comments identity-stamped from `currentUser` (delete only own —
client mirror of the server rule); Info panel shows an "Uploaded by" block + per-version author. `be-crm` still owns
`owner_principal_id`; `uploadedBy` is display-only.
PERSISTENCE: `StorageAdapter.applyChanges(StateChanges)` (diff-based; called INSTEAD of save() on debounced change;
serialized writes + rollback-on-reject; only non-system albums persisted) + `putMedia(id, blob, {name,mime}) →
StoredBlob {ref,url}` (durable bytes preferred over putBlob; sets `MediaItem.src` = url, `storageRef` = ref). Types
`StateChanges`/`StoredBlob` exported. Server-backed Recently-Deleted `lockProvider { status, set, verify }` replaces
the device-local `apg:lock-hash` entirely (`lockConfigured` = UI truth; `lockError` = 'wrong-password' vs 'unavailable').
METADATA/GEO: editable `caption` + new multi-line `MediaItem.note` in Info (save on blur/Enter via updateMedia; note
in the search haystack). `GeoLocation` gained road/neighbourhood/suburb/city/county/state/postcode/country/countryCode/
formatted; Info panel auto reverse-geocodes once via OSM Nominatim (`addressdetails=1`) + persists (host CSP must allow
`nominatim.openstreetmap.org`). MAP: fly-to on pin click, per-location count badge, hover mini-slider strip, location
sheet filter (object chips + single date-range control `.apg-daterange`).
UI FIXES (latest round, verified in code): (1) Info-panel VIDEO items render a real inline `<video>`
(`.apg-info__thumb--video`) not a frozen `<img>`; (2) extra spacing around "Uploaded by" / "Analyzing…" / between
comments (now cards `.apg-comment`) + a divider before the compose form; (3) the VIDEO EDITOR no longer applies the
rotate/flip/crop transform to a `<video controls>` — custom transport bar OUTSIDE the transformed frame
(`.apg-vedit__transport` / `.apg-vedit__scrub` / `.apg-vedit__time`); (4) map hover tooltip got `user-select:none` +
edge-clamping (`--tip-dx` + `.apg-pin__tip--below`) so it can't overflow/clip.
COLLECTIONS TRIM (2026-07-23): `CollectionsView.tsx` stripped of the placeholder-only sections — Memories, Pinned,
Featured Photos, People & Pets (removed entirely, incl. the interim "Pets"), Trips, Utilities. Remaining: Albums,
Objects, Shared Albums, Recent Days. Removed the now-unused `first`/`featured`/`hiddenViews`/`hidden`/`shown` locals +
dead `useState` import. (The sidebar's own Pinned/Utilities/People nav rows are separate and unchanged.)
DOCS (this session, no source changed, no git commit): root `README.md` (props table + embedding-props block +
incremental-adapter note + Platform "Embedding" row + reverse-geocode moved to done); `packages/photo-sdk/README.md`
(added Info-panel media/comments, video-editor transport, map-pin notes — the embedding/token/adapter guide already
existed); `docs/ARCHITECTURE.md` (Info-panel video preview + comment cards; map tooltip clamp); `docs/ROADMAP.md`
(new §11 Embedding into a host app; moved editable metadata / reverse-geocode / date-range / fullscreen / identity →
done); `docs/SETUP.md` (Supabase "planned adapter" → shipped); `CLAUDE.md` "What this is"; this memory.
## Key files
- Host-app embedding (S-crm-embed): `packages/photo-sdk/src/components/PhotoGallery.tsx` (props) + `store/store.ts`
(`GalleryConfig`/`ThemeTokens`/`GalleryChrome`/`DEFAULT_CHROME`/`LockProvider`/fullscreen/identity actions) +
`adapters/types.ts` (`StateChanges`/`StoredBlob`/`applyChanges`/`putMedia`) + `components/{InfoPanel,AppShell}.tsx` +
`components/views/MapView.tsx` + `components/editor/VideoEditor.tsx` + `styles/sdk.css`.
- RunPod integration (S-runpod): `apps/web/src/lib/runpod/*` (server) + `apps/web/src/app/api/ai/{edit,classify}/route.ts` + `apps/web/src/lib/ai/{runpodYoloProvider,imageEncode}.ts` (client). Env: `RUNPOD_API_KEY` + `RUNPOD_*_URL`.
- Advanced video editor (S-advanced): `packages/photo-sdk/src/lib/videoBake.ts` + `lib/videoTimeline.ts` + `components/editor/VideoEditor.tsx`.
- AI edit backend (S-deploy-ai): `apps/web/src/app/api/ai/edit/route.ts` (env AI_EDIT_PROVIDER).
+8
View File
@@ -27,6 +27,14 @@
# RUNPOD_BG_REMOVE_URL=https://api.runpod.ai/v2/<id>/runsync # #6 (optional; default is in-browser @imgly)
# RUNPOD_YOLO_URL=https://api.runpod.ai/v2/<id>/runsync # #1 detection (needs the flag below)
# NEXT_PUBLIC_APG_RUNPOD_DETECT=false # true = use #1 YOLO server detection (else free in-browser COCO-SSD)
# NEXT_PUBLIC_APG_RUNPOD_BG=false # true = use the #6 U²-Net endpoint for Remove Background
# # (needs RUNPOD_BG_REMOVE_URL). Falls back to the free
# # in-browser @imgly remover if the request fails.
# NEXT_PUBLIC_APG_RUNPOD_TILT=false # true = show the editor's Auto-straighten button and call
# # /api/ai/tilt (#2). Needs RUNPOD_TILT_URL deployed;
# # leave false and the button stays hidden.
# All three switches are NEXT_PUBLIC_ (inlined at BUILD time) and must be the literal
# string "true" to enable — anything else is off, and a change needs a rebuild.
# Optional SD tuning: RUNPOD_SD_STEPS RUNPOD_SD_STRENGTH RUNPOD_SD_GUIDANCE RUNPOD_SD_NEGATIVE_PROMPT
# Scaffold-only (typed client, no route/UI yet): RUNPOD_TILT_URL (#2) RUNPOD_STT_URL (#3)
# RUNPOD_AUDIO_DENOISE_URL (#12) RUNPOD_VIDEO_FPS_URL (#13) RUNPOD_VIDEO_UPSCALE_URL (#14)
+10 -2
View File
@@ -9,8 +9,12 @@
A **reusable macOS Photosstyle photo gallery SDK** (`@photo-gallery/sdk`) for React / Next.js, plus a
Next.js demo (`apps/web`). Goal: a near carbon-copy of macOS Photos (light/dark/semi-dark, fully
responsive) where **every feature is optional via flags**, so each consuming project enables only what
it needs. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the complete, living feature checklist
(done / in-progress / planned) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the design.
it needs. It is **not just the standalone demo** — the SDK now **embeds into a host app** (e.g. the
LynkedUp CRM): an `embedded` layout, suppressible `chrome`, host-driven theming (full `ThemeTokens` map),
host **identity** (`currentUser` → identity-stamped comments / uploads / versions), a server-backed
Recently-Deleted `lockProvider`, full-screen mode, and an **incremental** `StorageAdapter`
(`applyChanges` + `putMedia`). See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the complete, living feature
checklist (done / in-progress / planned) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the design.
## Monorepo
@@ -25,6 +29,10 @@ it needs. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the complete, living feat
- Storage is a pluggable `StorageAdapter` (default: localStorage/IndexedDB). AI is a pluggable
`AIProvider` (object detection / faces / OCR / caption / embeddings / generativeEdit) — all optional.
- Every capability is gated by a feature flag (`features={{ editor, camera, ai, map, import, export, ... }}`).
- **Embedding the SDK in another app** (embedded layout, chrome suppression, host identity/`currentUser`,
the full `ThemeTokens` → CSS-var table, and the incremental `applyChanges`/`putMedia` adapter contract):
see [`packages/photo-sdk/README.md` → "Embedding in a host app"](packages/photo-sdk/README.md#embedding-in-a-host-app)
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
- Security: strict per-request **nonce CSP** (middleware), input validation, URL-scheme allow-list,
filename sanitization, server-only API keys (never `NEXT_PUBLIC_`). Don't regress this.
+33 -8
View File
@@ -55,9 +55,10 @@ what it needs. The full, living checklist is in [`docs/ROADMAP.md`](docs/ROADMAP
### Platform
| Area | Details |
| --- | --- |
| **Backend** | Pluggable `StorageAdapter` — zeroconfig localStorage/IndexedDB default, or **Supabase** (Postgres + Storage) adapter included |
| **Search** | Name / place / tag / object / **OCR text** / **semantic** — blended and relevanceranked |
| **Theming** | `accentColor` + `borderRadius` props; Light / Dark / SemiDark / System |
| **Backend** | Pluggable `StorageAdapter` — zeroconfig localStorage/IndexedDB default, or **Supabase** (Postgres + Storage) adapter included; optional **incremental** `applyChanges` + durable `putMedia` for backendfriendly partial writes |
| **Embedding** | Drop into a host app: `embedded` layout, `chrome` suppression, `hiddenViews`, host **`currentUser`** identity (identitystamped comments / uploads / versions), `shareBaseUrl`, serverbacked **`lockProvider`**, fullscreen mode — live config (no remount) |
| **Search** | Name / place / tag / object / **OCR text** / caption / note / **semantic** — blended and relevanceranked |
| **Theming** | `accentColor` + `borderRadius` props **and a full `ThemeTokens` map (50+ keys)** → CSS variables; Light / Dark / SemiDark / System |
| **A11y / responsive** | Keyboard nav, focus traps, reducedmotion, ARIA; works down to ~280 px |
| **Security** | Perrequest **nonce CSP**, security headers, filename sanitisation, URLscheme allowlist, serveronly API keys |
@@ -149,14 +150,29 @@ export default function Gallery() {
| --- | --- | --- | --- |
| `photos` | `MediaItem[] \| { src, … }[]` | `[]` | Initial library; loose inputs are normalised + autoclassified |
| `albums` | `Album[]` | `[]` | Initial user albums |
| `adapter` | `StorageAdapter` | localStorage | Persistence backend (swap for S3/DB/REST) |
| `adapter` | `StorageAdapter` | localStorage | Persistence backend (swap for S3/DB/REST); supports incremental `applyChanges` + durable `putMedia` |
| `ai` | `AIProvider \| boolean` | `false` | Object/face/caption/search/generative provider |
| `theme` | `'system' \| 'light' \| 'dark'` | `'system'` | Appearance |
| `theme` | `'system' \| 'light' \| 'dark' \| 'semi-dark'` | `'system'` | Appearance |
| `accentColor` | `string` | `#0a84ff` | Accent colour |
| `borderRadius` | `number` | `10` | Base corner radius (px) for all rounded UI |
| `themeTokens` | `ThemeTokens` | | Pertheme background / gradient / opacity / sidebar colour + radius overrides |
| `themeTokens` | `ThemeTokens` | | Full pertheme token map (50+ keys) → CSS variables (backgrounds, cards, glass, separators, text tiers, shadows, radii, sidebar/toolbar sizing…) |
| `features` | `Partial<GalleryFeatures>` | all on | Toggle editor / camera / ai / map / import / export / sharing |
| `showWindowChrome` | `boolean` | `false` | Render the macOS trafficlight title bar |
| `showWindowChrome` | `boolean` | `false` | Render the macOS trafficlight title bar (legacy alias of `chrome.titlebar`) |
**Embedding into a host app** — these props let the gallery drop into an existing product shell instead of running standalone. Full guide, token table and adapter contract: **[`packages/photo-sdk/README.md` → “Embedding in a host app”](packages/photo-sdk/README.md#embedding-in-a-host-app)**.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `embedded` | `boolean` | `false` | Lay out at `height:100%` inside the host container (not a full viewport); `cursor:pointer` on controls |
| `chrome` | `Partial<GalleryChrome>` | `DEFAULT_CHROME` | Suppress the gallery's own `{ titlebar, sidebar, toolbar, themeSwitcher }` when the host provides them |
| `hiddenViews` | `ViewId[]` | `[]` | Hide sidebar rows + Collections cards, e.g. `['screenshots', 'sys:documents']` |
| `keyboardShortcuts` | `boolean` | `true` | Bind global shortcuts (⌘A, Delete, F, Esc, Enter) to `window` |
| `currentUser` | `GalleryUser` | | Host's signedin user; stamps comments / uploads / versions with real identity |
| `shareBaseUrl` | `string` | `${origin}/gallery` | Base URL for generated share links |
| `defaultFullscreen` | `boolean` | `false` | Start maximised over the host page (`apg--fullscreen`) |
| `lockProvider` | `LockProvider` | | Serverbacked, peruser RecentlyDeleted lock (replaces the devicelocal hash) |
The live config is applied on every render, so changing `theme`, `themeTokens`, `currentUser` or `chrome` reapplies without remounting the gallery.
### 🎨 Customization (props **or** env)
@@ -188,6 +204,14 @@ const myApiAdapter: StorageAdapter = {
<PhotoGallery adapter={myApiAdapter} />;
```
Two optional members make an adapter backendfriendly (both additive — `save`/`putBlob`only adapters keep working):
`applyChanges(changes: StateChanges)` — called *instead of* `save()` on each debounced change, sending only
`{ upsertMedia?, removeMedia?, upsertAlbums?, removeAlbums?, upsertPeople?, removePeople?, labelAliases?, deletedLabels? }`
(a rejection rolls the snapshot back and the next persist retries the same diff); and
`putMedia(id, blob, { name, mime }): Promise<{ ref, url }>` — durable byte storage preferred over `putBlob`, where
`url``MediaItem.src` (renderable now, may be a signed URL) and `ref``MediaItem.storageRef` (survives a reload).
See [`packages/photo-sdk/README.md` → “Incremental storage adapters”](packages/photo-sdk/README.md#incremental-storage-adapters).
### Custom AI provider
Every method is optional and the UI degrades gracefully. Implement object detection clientside
@@ -250,7 +274,8 @@ Still planned / not yet implemented:
- **Auth & multiuser sharing** (real recipients, permissions). Sharing today creates devicelocal links.
- **Captions** (transformers.js BLIP) and a firstclass Hugging Face Inference provider option.
- **Map** heatmaps / trips / reversegeocode place names; true grid **virtualization** for millions of items.
- **Map** heatmaps / trips; true grid **virtualization** for millions of items. (Fulladdress **reversegeocoding**
— road → city → country via OpenStreetMap Nominatim, shown in the Info panel — is now live.)
- **Serverside** 30day purge (pg_cron), Storage blob deletion on permanentdelete.
- Storybook + API docs, Docker/K8s, automated CI.
+83 -2
View File
@@ -25,10 +25,85 @@ A pluggable `StorageAdapter` loads/saves state (debounced). Inputs are normalize
`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?(id, blob), clear?() }`.
`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`; `<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 10001300 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 `<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:
@@ -39,7 +114,13 @@ A pluggable `StorageAdapter` loads/saves state (debounced). Inputs are normalize
## 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*`. All feature toggles via `features`.
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`](../packages/photo-sdk/README.md#theme-tokens--css-variables).
All feature toggles via `features`.
## Security
Per-request **nonce CSP** in `apps/web/src/middleware.ts` (`script-src 'self' 'nonce-…' 'strict-dynamic'`,
+40 -2
View File
@@ -25,7 +25,7 @@ designed to be **optional via flags** (`features={{ … }}`) — a consuming pro
## 2. Library, viewing & navigation
- ✅ Library views: Years / Months / All Photos (Days grouping supported) — **date/time grouping**
- ✅ Collections (Memories, Pinned, Albums, People, Featured, Shared, Recent Days, Trips, Utilities, Objects)
- ✅ Collections (Albums, Objects, Shared Albums, Recent Days) — trimmed of the placeholder-only sections (Memories / Pinned / Featured Photos / People & Pets / Trips / Utilities) for host-app embedding
- ✅ Adaptive responsive grid + zoom in/out, lazy images
- ✅ **Lightbox** photo viewer (prev/next, keyboard, actions) — 🚧 polish to 100% macOS parity
- ✅ **Video player** (native controls in lightbox) — 🚧 macOS-style custom controls/scrubber
@@ -58,6 +58,15 @@ designed to be **optional via flags** (`features={{ … }}`) — a consuming pro
location's photos in a **date-grouped sheet**; any GPS item (incl. screenshots) pins automatically
- ✅ **Grid tab = Memories mosaic**: date sections, full-width **auto-sliding hero** + irregular varied tiles
- ✅ **Mini-map** in the photo info panel
- ✅ **Fly-to on pin click** (`flyTo`, never zooms out) + per-location **count badge** + **hover mini-slider** strip;
the single-photo hover tooltip is edge-clamped (`--tip-dx` / `.apg-pin__tip--below`) and `user-select:none`
- ✅ **Location-sheet filter**: search (shared `searchMedia`) + object-label chips + a single **date-range** control
(`.apg-daterange` — presets *All dates / Last 7 days / Last 30 days / This year* + custom From/To), all AND-combined,
with a live "N of M" count and one Clear-all
- ✅ **Full-address reverse geocoding** — on opening a located item the Info panel reverse-geocodes once via OpenStreetMap
Nominatim (`addressdetails=1`), parsing road/neighbourhood/suburb/city/county/state/postcode/country/countryCode/formatted
into `GeoLocation` and persisting it via `updateMedia` (survives reload, never re-fetched). Host CSP must allow
`nominatim.openstreetmap.org` in `connect-src`
- Note: demo seed intentionally locates ~half the photos; real photos populate via EXIF GPS (§4)
## 6. Editor (photo & video)
@@ -92,7 +101,8 @@ designed to be **optional via flags** (`features={{ … }}`) — a consuming pro
> From a full feature audit vs. macOS Photos. HIGH = most-requested / highest value.
- ✅ **Custom video player** (glass scrubber, buffered/played, volume, PiP, fullscreen, keyboard)
- ⬜ **HIGH — Lightbox zoom/pan** (scroll / double-click / pinch) for detail viewing
- **HIGH — Editable metadata** in the Info panel (caption, keywords/tags, title; bulk edit)
- **Editable metadata** in the Info panel — clicktoedit **caption** + multiline **`note`** (save on blur / Enter via
`updateMedia`; `note` is folded into the search haystack). ⬜ still: keywords/tags editing, title, bulk edit
- ⬜ **HIGH — Slideshow / Memories playback** (auto-advance + Ken Burns transitions)
- ⬜ MED — Lightbox **filmstrip** of adjacent media; **timeline scrubber** to jump by date
- ⬜ MED — **Keyboard-shortcut help** modal (`?`); album **drag-to-reorder**
@@ -127,6 +137,34 @@ designed to be **optional via flags** (`features={{ … }}`) — a consuming pro
- ✅ Docs: README + `docs/SETUP.md` + `docs/ARCHITECTURE.md` + this roadmap + project-local `CLAUDE.md`
- ✅ Testing: Playwright e2e (manual runs); ⬜ automated CI suite
## 11. Embedding into a host app (CRM / host integration)
> The SDK is no longer just the macOS-Photos demo — it embeds into a host product's shell. All of it is opt-in.
- ✅ **Embedded layout** (`embedded`) — fills the host container (`height:100%`, `.apg--embedded`, `cursor:pointer`)
instead of assuming a full viewport; overlays stay `position:fixed` and honour `--apg-overlay-top`
- ✅ **Chrome suppression** (`chrome { titlebar, sidebar, toolbar, themeSwitcher }`, `DEFAULT_CHROME` exported) — hide the
pieces the host already provides; `showWindowChrome` kept as a legacy alias of `chrome.titlebar`
- ✅ **`hiddenViews: ViewId[]`** — drop sidebar rows + Collections cards (labels drop when empty; active hidden view falls back to `library`)
- ✅ **`keyboardShortcuts`** flag — bind (or not) global shortcuts to `window`
- ✅ **Host identity** (`currentUser: GalleryUser`) — identity-stamped **comments** (`authorId/author/authorAvatar`;
only the author sees delete), **uploads** (`MediaItem.uploadedBy`, never overwritten on re-import), and **versions**
(`addVersion`/`restoreVersion` stamp the author). Info panel shows an "Uploaded by" block + per-version author.
`be-crm` still owns `owner_principal_id`; `uploadedBy` is display identity only
- ✅ **`shareBaseUrl`** — host owns where share links point
- ✅ **Full ThemeTokens map (50+ keys)** → CSS variables (backgrounds, cards, toolbar/menu glass, separators, hover/active,
text tiers, glass border, font, shadows, tile-fav, overlay/editor bg, segmented pill, sidebar/toolbar sizing);
`xLight/xDark` theme-paired or bare; sanitized against `url()/expression()/javascript:/<>{}`. **Live config** — theme /
tokens / identity / chrome re-apply via `setConfig` without remounting
- ✅ **Full-screen / maximise** (`defaultFullscreen`, store `fullscreen`/`setFullscreen`/`toggleFullscreen`, toolbar button) —
adds `.apg--fullscreen` (`position:fixed; inset:0; z-index:1400; height:100dvh`); Escape un-maximises after the
lightbox/editor/camera/modal/menu checks
- ✅ **Server-backed Recently-Deleted lock** (`lockProvider: { status, set, verify }`) — replaces the device-local
`apg:lock-hash` entirely; `lockConfigured` is the UI source of truth, `lockError` separates `'wrong-password'` from `'unavailable'`
- ✅ **Incremental persistence**`StorageAdapter.applyChanges(StateChanges)` (diff-based, called instead of `save()`;
serialized writes, rollback-on-reject) + `putMedia(id, blob, meta) → { ref, url }` (durable bytes, sets
`MediaItem.src`/`storageRef`); types `StateChanges` / `StoredBlob` exported
- ✅ **Floating glass toolbar** + library-scale **dropdown** (`.apg-scalemenu`, replaces the Years/Months/All segmented)
- ⬜ Real multi-user auth / permissions in the host (identity is display-only in the SDK; the backend is the authority)
## Web-meaningful UI (done 2026-06-29)
- ✅ "All Projects / App Store" (iOS-only) → **"All Albums"** overview (My Albums + Create + Recent Days).
- ✅ Sidebar Albums section: All Albums + New Album + user albums (indented).
+1 -1
View File
@@ -30,7 +30,7 @@ Copy `.env.example` → `apps/web/.env.local` (gitignored — never commit). Key
GEMINI_API_KEY=your-key
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
# Supabase backend (optional, planned adapter):
# Supabase backend (optional; adapter included — run docs/supabase-setup.sql once to activate):
# NEXT_PUBLIC_SUPABASE_URL=...
# SUPABASE_SERVICE_ROLE_KEY=... # server-side only
```
+263
View File
@@ -13,6 +13,269 @@ export default () => <PhotoGallery photos={myPhotos} theme="system" />;
See the [repository README](../../README.md) for full documentation, props, adapters and the
AI-provider interface.
## Embedding in a host app
By default `<PhotoGallery>` 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
<div style={{ height: 'calc(100vh - 64px)' }}>
<PhotoGallery
embedded
adapter={dataDoorAdapter}
currentUser={{ id: user.id, name: user.name, avatarUrl: user.avatar }}
shareBaseUrl="https://app.example.com/gallery"
chrome={{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: false }}
keyboardShortcuts={false}
theme={hostTheme}
themeTokens={hostTokens}
/>
</div>
```
### 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=<token>` is appended). |
| `chrome` | `Partial<GalleryChrome>` | `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 10001300 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
<PhotoGallery
embedded
lockProvider={{
status: () => 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<void>; // null clears it
verify(password: string): Promise<boolean>;
}
```
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<StoredBlob>`** — 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<any>): 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<StoredBlob> {
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 `<video controls>` preview (`.apg-info__thumb--video`) at the top of the
panel instead of a frozen poster `<img>`, so a video is playable straight from Info. Comments render as
cards (`.apg-comment`) with generous spacing around the "Uploaded by" block, the "Analyzing…" line and
between comments, and a divider separates the thread from the compose form.
### Full-address reverse geocoding
When a located item's Info panel opens and `location.formatted` isn't cached yet, the SDK reverse-
geocodes the coordinates once via free OpenStreetMap Nominatim (`addressdetails=1`), parses the result
into `GeoLocation` (`road`, `neighbourhood`, `suburb`, `city`, `county`, `state`, `postcode`,
`country`, `countryCode`, `formatted`), and persists it with `updateMedia` so it survives a reload and
isn't re-fetched. The panel shows the one-line address plus a City / State / Postcode / Country grid
and the raw lat/lng; clicking the address opens the Map. A hardened host CSP must allow
`nominatim.openstreetmap.org` in `connect-src`.
### Floating toolbar
The top toolbar now floats as a rounded glass bar (margins on all sides, `--apg-radius-lg`,
`--apg-shadow-md`) to match the sidebar, in embedded and full-screen alike. It stays in normal flow, so
the viewport begins below it and scrolling is unaffected. The wide Years/Months/All-Photos segmented is
replaced by a compact **"All Photos ▾"** dropdown (`.apg-scalemenu`, reusing the `.apg-menu` popover
with its menu role, checkmarks and arrow-key nav) — this clears the zoom-out () button that the
segmented used to overlap on narrow toolbars.
### Map date-range control
The Map location sheet's filter replaces the two bare From/To date inputs with a single **date range**
button (`.apg-daterange`) that opens a popover of quick presets (All dates, Last 7 days, Last 30 days,
This year) plus a custom From/To pair. It drives the same underlying from/to state, so the AND-combine
with search + object chips, the live "N of M" count, and Clear all keep working.
Map pins fly-to on click (`flyTo`, never zooming out), carry a per-location count badge and a hover
mini-slider strip; the single-photo hover tooltip (`.apg-pin__tip`) is `user-select:none` and edge-clamped
— MapView sets `--tip-dx` to slide it back inside the map and adds `.apg-pin__tip--below` to flip it under
the pin near the top edge — so it can't overflow or get clipped.
### Video editor transport
The video editor applies its rotate / flip / crop transform to the video *frame only*, never to a native
`<video controls>` bar (which used to rotate with the frame and look broken). Playback is driven by a custom
transport rendered outside the transformed element — play/pause, a scrubber and a time readout
(`.apg-vedit__transport` / `.apg-vedit__scrub` / `.apg-vedit__time`).
## Build
```bash
+54 -1
View File
@@ -1,4 +1,4 @@
import type { Album, MediaItem, Person } from '../types';
import type { Album, AlbumId, MediaId, MediaItem, Person, PersonId } from '../types';
export interface PersistedState {
media: MediaItem[];
@@ -13,6 +13,42 @@ export interface PersistedState {
version: number;
}
/**
* An incremental diff of the library, produced by the store on every persist.
*
* Only the entities that actually changed since the previous successful persist
* are included: an entity appears in `upsert*` when it is new or its object
* reference changed, and in `remove*` when it disappeared from state. The label
* maps are included only when they differ from the last persisted values.
* Every field is optional an empty object means "nothing changed".
*/
export interface StateChanges {
/** Media items that were created or modified since the last persist. */
upsertMedia?: MediaItem[];
/** Ids of media items that no longer exist (permanently deleted). */
removeMedia?: MediaId[];
/** User albums (never system albums) created or modified since the last persist. */
upsertAlbums?: Album[];
/** Ids of user albums that no longer exist. */
removeAlbums?: AlbumId[];
/** People/pet clusters created or modified since the last persist. */
upsertPeople?: Person[];
/** Ids of people/pet clusters that no longer exist. */
removePeople?: PersonId[];
/** Whole label-alias map — sent only when it changed (shallow compare). */
labelAliases?: Record<string, string>;
/** Whole deleted-label list — sent only when it changed (shallow compare). */
deletedLabels?: string[];
}
/** The result of a durable byte upload: an adapter-owned ref plus a readable URL. */
export interface StoredBlob {
/** Durable, adapter-owned reference for the bytes. Survives reloads. */
ref: string;
/** A URL the browser can render right now (may be a short-lived signed URL). */
url: string;
}
/**
* Storage adapter contract. Implement this to back the gallery with anything:
* localStorage (default), IndexedDB, a REST/GraphQL API, S3, Postgres, etc.
@@ -28,4 +64,21 @@ export interface StorageAdapter {
/** Persist a binary blob and return a stable URL to it. */
putBlob?(id: string, blob: Blob): Promise<string>;
clear?(): Promise<void>;
/**
* Incremental persistence. When present the store calls this INSTEAD of
* `save()` on every (debounced) change, passing only the entities that
* differ from the previously persisted snapshot. Implement it when the
* backend can apply partial writes it avoids re-uploading the whole
* library on every favourite toggle. If this rejects, the store rolls its
* snapshot back so the next persist retries the same changes.
*/
applyChanges?(changes: StateChanges): Promise<void>;
/**
* Durable byte storage. When present it is preferred over `putBlob()` for
* imports and camera captures: the store sets `item.src = url` (renderable
* now) and `item.storageRef = ref` (what survives a reload, so the host can
* re-sign the URL later). Falls back to `putBlob()` and finally to an
* inlined `data:` URL when absent.
*/
putMedia?(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob>;
}
+25 -9
View File
@@ -13,8 +13,22 @@ import { ViewRouter } from './ViewRouter';
export function AppShell() {
const api = useGalleryStoreApi();
const sidebarOpen = useGallery((s) => s.sidebarOpen);
const showSidebar = useGallery((s) => s.config.chrome.sidebar);
const showToolbar = useGallery((s) => s.config.chrome.toolbar);
const shortcutsEnabled = useGallery((s) => s.config.keyboardShortcuts);
const view = useGallery((s) => s.view);
const hiddenViews = useGallery((s) => s.config.hiddenViews);
const isMobile = useIsMobile();
useKeyboardShortcuts();
// If the active view becomes hidden (host toggled `hiddenViews`, or a deep link
// landed on one), fall back to the Library instead of rendering a dead view.
useEffect(() => {
if (hiddenViews && hiddenViews.includes(view) && view !== 'library') {
api.getState().setView('library');
}
}, [api, view, hiddenViews]);
// The hook is always called (stable hook order); it binds nothing when disabled.
useKeyboardShortcuts(shortcutsEnabled);
// Collapse the sidebar by default on phones; expand on larger screens.
const lastMobile = useRef<boolean | null>(null);
@@ -26,15 +40,17 @@ export function AppShell() {
return (
<>
<Sidebar />
<div
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
.filter(Boolean)
.join(' ')}
onClick={() => api.getState().setSidebar(false)}
/>
{showSidebar ? <Sidebar /> : null}
{showSidebar ? (
<div
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
.filter(Boolean)
.join(' ')}
onClick={() => api.getState().setSidebar(false)}
/>
) : null}
<div className="apg-main">
<TopToolbar />
{showToolbar ? <TopToolbar /> : null}
<div className="apg-viewport">
<ViewRouter />
<SelectionBar />
@@ -211,6 +211,8 @@ export function Camera() {
duration,
location,
exif,
// Stamp who captured this (display identity) when the host supplies a user.
uploadedBy: api.getState().config.currentUser,
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
});
api.getState().addMedia([item]);
@@ -47,6 +47,11 @@ export function closeContextMenu() {
}
}
/** Whether a context menu is open (it owns Escape while it is). */
export function isContextMenuOpen(): boolean {
return current !== null;
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
+345 -86
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { sourceLabel } from '../lib/classify';
import { editFilterCss, editTransformCss } from '../lib/edits';
@@ -9,7 +9,7 @@ import { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from
import { Icon } from '../icons';
import { useAIProvider } from './aiContext';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaItem } from '../types';
import type { GalleryUser, GeoLocation, MediaItem } from '../types';
export function InfoPanel() {
const api = useGalleryStoreApi();
@@ -45,12 +45,44 @@ export function InfoPanel() {
<div className="apg-info__empty">Select a single photo to see its details.</div>
) : (
<div className="apg-info__body">
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
{item.kind === 'video' ? (
// Videos get a real, playable preview at the top of the panel — the same
// affordance images have, not a frozen poster frame.
<video
className="apg-info__thumb apg-info__thumb--video"
src={item.src}
poster={item.poster ?? item.thumbnail}
controls
playsInline
preload="metadata"
/>
) : (
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
)}
<div className="apg-info__name">{item.name}</div>
<div className="apg-info__sub">
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
</div>
{/* Editable caption (single line) + note (multi-line) — persisted via updateMedia. */}
<EditableField
key={`cap-${item.id}`}
label="Caption"
value={item.caption}
placeholder="Add a caption…"
onSave={(v) => api.getState().updateMedia(item.id, { caption: v })}
/>
<EditableField
key={`note-${item.id}`}
label="Note"
value={item.note}
placeholder="Add a note…"
multiline
onSave={(v) => api.getState().updateMedia(item.id, { note: v })}
/>
{item.uploadedBy ? <UploadedBy user={item.uploadedBy} /> : null}
{item.kind === 'image' && !item.analyzedAt ? (
<div className="apg-info__analyzing">
<span className="apg-info__spinner" aria-hidden />
@@ -114,21 +146,18 @@ export function InfoPanel() {
<Row
label="Location"
value={
item.location.formatted ??
item.location.place ??
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
}
/>
<div className="apg-info__coords">
{item.location.lat.toFixed(5)}, {item.location.lng.toFixed(5)}
</div>
<MiniMap
lat={item.location.lat}
lng={item.location.lng}
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
/>
<AddressLookup
lat={item.location.lat}
lng={item.location.lng}
<AddressBlock
item={item}
onOpenMap={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
/>
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
@@ -198,6 +227,18 @@ function Versions({ item }: { item: MediaItem }) {
<span className="apg-version__time">
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
</span>
{v.author ? (
<span className="apg-version__author">
{v.authorAvatar ? (
<img className="apg-version__author-avatar" src={v.authorAvatar} alt="" />
) : (
<span className="apg-version__author-avatar apg-version__author-avatar--initial" aria-hidden>
{v.author.slice(0, 1).toUpperCase()}
</span>
)}
<span className="apg-version__author-name">{v.author}</span>
</span>
) : null}
</span>
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
</button>
@@ -232,8 +273,11 @@ function Versions({ item }: { item: MediaItem }) {
/** Threaded comments on a photo/video. */
function Comments({ item }: { item: MediaItem }) {
const api = useGalleryStoreApi();
const currentUser = useGallery((s) => s.config.currentUser);
const comments = item.comments ?? [];
const [text, setText] = useState('');
// Without a host-provided user we keep the legacy free-text author field
// (remembered in localStorage). With one, identity comes from the host.
const [author, setAuthor] = useState(() => {
if (typeof window === 'undefined') return 'You';
return window.localStorage.getItem('apg:comment-author') || 'You';
@@ -285,6 +329,12 @@ function Comments({ item }: { item: MediaItem }) {
const post = () => {
const t = text.trim();
if (!t) return;
if (currentUser) {
// The store stamps authorId/author/avatar from config.currentUser.
api.getState().addComment(item.id, t);
setText('');
return;
}
const name = author.trim() || 'You';
try {
window.localStorage.setItem('apg:comment-author', name);
@@ -307,42 +357,66 @@ function Comments({ item }: { item: MediaItem }) {
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
) : (
<ul className="apg-comments">
{comments.map((c) => (
<li key={c.id} className="apg-comment">
<div className="apg-comment__avatar" aria-hidden>
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
</div>
<div className="apg-comment__body">
<div className="apg-comment__meta">
<span className="apg-comment__author">{c.author ?? 'You'}</span>
<span className="apg-comment__time">
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
</span>
{comments.map((c) => {
// With a configured user, only their OWN comments are deletable
// (mirrors the server rule enforced on authorId).
const canDelete = !currentUser || c.authorId === currentUser.id;
return (
<li key={c.id} className="apg-comment">
{c.authorAvatar ? (
<img className="apg-comment__avatar-img" src={c.authorAvatar} alt="" />
) : (
<div className="apg-comment__avatar" aria-hidden>
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
</div>
)}
<div className="apg-comment__body">
<div className="apg-comment__meta">
<span className="apg-comment__author">{c.author ?? 'You'}</span>
<span className="apg-comment__time">
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
</span>
</div>
<div className="apg-comment__text">{c.text}</div>
</div>
<div className="apg-comment__text">{c.text}</div>
</div>
<button
type="button"
className="apg-comment__delete"
aria-label="Delete comment"
onClick={() => api.getState().deleteComment(item.id, c.id)}
>
<Icon name="close" size={13} />
</button>
</li>
))}
{canDelete ? (
<button
type="button"
className="apg-comment__delete"
aria-label="Delete comment"
onClick={() => api.getState().deleteComment(item.id, c.id)}
>
<Icon name="close" size={13} />
</button>
) : null}
</li>
);
})}
</ul>
)}
<div className="apg-comment-form">
<input
className="apg-comment-form__author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="Your name"
aria-label="Your name"
maxLength={40}
/>
{currentUser ? (
<div className="apg-comment-form__identity">
{currentUser.avatarUrl ? (
<img className="apg-comment__avatar-img" src={currentUser.avatarUrl} alt="" />
) : (
<div className="apg-comment__avatar" aria-hidden>
{(currentUser.name || '?').slice(0, 1).toUpperCase()}
</div>
)}
<span className="apg-comment__author">{currentUser.name}</span>
</div>
) : (
<input
className="apg-comment-form__author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="Your name"
aria-label="Your name"
maxLength={40}
/>
)}
<textarea
className="apg-comment-form__input"
value={text}
@@ -452,68 +526,253 @@ function Chips({
);
}
/**
* "Show address" button reverse-geocodes the GPS coords to a human-readable
* address (free OpenStreetMap Nominatim). Clicking the resolved address opens the
* full Map at that location.
*/
function AddressLookup({ lat, lng, onOpenMap }: { lat: number; lng: number; onOpenMap: () => void }) {
const [address, setAddress] = useState<string | null>(null);
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
// Cross-instance guards so re-opening a panel (or a second panel) never double-
// fetches the same item: ids currently in flight, and ids that already failed
// (so the auto-fetch doesn't loop — the user must press Retry).
const geocodeInFlight = new Set<string>();
const geocodeFailed = new Set<string>();
const lookup = async () => {
setState('loading');
/** Shape of Nominatim's `address` object (addressdetails=1) — all fields optional. */
interface NominatimAddress {
road?: string;
neighbourhood?: string;
suburb?: string;
city?: string;
town?: string;
village?: string;
county?: string;
state?: string;
postcode?: string;
country?: string;
country_code?: string;
}
/**
* Reverse-geocodes an item's GPS coords to a full postal address via free
* OpenStreetMap Nominatim, then PERSISTS the parsed fields onto the item
* (`updateMedia`) so it saves through the adapter and survives a reload. Runs
* automatically the first time a located item's Info panel opens (only when
* `location.formatted` isn't already cached); manual Retry on error.
*
* NOTE: a hardened host CSP must allow `nominatim.openstreetmap.org` in
* `connect-src` for this fetch to succeed.
*/
function AddressBlock({ item, onOpenMap }: { item: MediaItem; onOpenMap: () => void }) {
const api = useGalleryStoreApi();
const loc = item.location!;
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
const lookup = useCallback(async () => {
// One request per id at a time (etiquette + no double-fetch across panels).
if (geocodeInFlight.has(item.id)) return;
geocodeInFlight.add(item.id);
setStatus('loading');
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`,
`https://nominatim.openstreetmap.org/reverse?lat=${loc.lat}&lon=${loc.lng}&format=json&zoom=18&addressdetails=1`,
{ headers: { Accept: 'application/json' } },
);
const data = (await res.json()) as { display_name?: string };
if (data?.display_name) {
setAddress(data.display_name);
setState('idle');
const data = (await res.json()) as { display_name?: string; address?: NominatimAddress };
const a = data.address ?? {};
if (data.display_name) {
const parsed: Partial<GeoLocation> = {
road: a.road,
neighbourhood: a.neighbourhood,
suburb: a.suburb,
city: a.city ?? a.town ?? a.village,
county: a.county,
state: a.state,
postcode: a.postcode,
country: a.country,
countryCode: a.country_code ? a.country_code.toUpperCase() : undefined,
formatted: data.display_name,
};
// Persist through the adapter (→ be-crm) so it survives reload + isn't refetched.
api.getState().updateMedia(item.id, { location: { ...loc, ...parsed } });
geocodeFailed.delete(item.id);
setStatus('idle');
} else {
setState('error');
geocodeFailed.add(item.id);
setStatus('error');
}
} catch {
setState('error');
geocodeFailed.add(item.id);
setStatus('error');
} finally {
geocodeInFlight.delete(item.id);
}
}, [api, item.id, loc]);
// Auto-fetch once when a located item without a cached address opens. The
// `!loc.formatted` guard (and the failed-set) prevent a re-fetch loop: once
// persisted, the item re-renders WITH `formatted` and this no-ops.
useEffect(() => {
if (!loc.formatted && !geocodeFailed.has(item.id) && !geocodeInFlight.has(item.id)) {
void lookup();
}
}, [item.id, loc.formatted, lookup]);
const retry = () => {
geocodeFailed.delete(item.id);
void lookup();
};
if (address) {
if (loc.formatted) {
const cells: Array<[string, string | undefined]> = [
['City', loc.city],
['State', loc.state],
['Postcode', loc.postcode],
['Country', loc.country],
];
return (
<button
type="button"
onClick={onOpenMap}
title="Open in Map"
style={{
display: 'block',
textAlign: 'left',
width: '100%',
marginTop: 6,
padding: '6px 8px',
background: 'var(--apg-bg-elevated)',
border: '1px solid var(--apg-glass-border, rgba(255,255,255,0.1))',
borderRadius: 8,
color: 'var(--apg-text)',
fontSize: 12,
cursor: 'pointer',
}}
>
📍 {address}
</button>
<div className="apg-address">
<button type="button" className="apg-address__line" onClick={onOpenMap} title="Open in Map">
<Icon name="map" size={14} />
<span>{loc.formatted}</span>
</button>
<dl className="apg-address__grid">
{cells
.filter(([, v]) => Boolean(v))
.map(([k, v]) => (
<div key={k} className="apg-address__cell">
<dt>{k}</dt>
<dd>{v}</dd>
</div>
))}
</dl>
<div className="apg-info__coords">
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
</div>
</div>
);
}
return (
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() => void lookup()}
disabled={state === 'loading'}
style={{ marginTop: 6 }}
>
{state === 'loading' ? 'Looking up…' : state === 'error' ? 'Retry address' : 'Show address'}
<div className="apg-address">
<div className="apg-info__coords">
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
</div>
{status === 'loading' ? (
<div className="apg-info__hint" aria-live="polite">
Looking up address
</div>
) : status === 'error' ? (
<button type="button" className="apg-btn apg-btn--small" onClick={retry} style={{ marginTop: 6 }}>
Retry address
</button>
) : null}
</div>
);
}
/** "Uploaded by" identity block: avatar + name + (muted) email. */
function UploadedBy({ user }: { user: GalleryUser }) {
return (
<div className="apg-info__uploader">
<span className="apg-info__uploader-label">Uploaded by</span>
<div className="apg-info__uploader-row">
{user.avatarUrl ? (
<img className="apg-info__uploader-avatar" src={user.avatarUrl} alt="" />
) : (
<div className="apg-info__uploader-avatar apg-info__uploader-avatar--initial" aria-hidden>
{(user.name || '?').slice(0, 1).toUpperCase()}
</div>
)}
<div className="apg-info__uploader-meta">
<span className="apg-info__uploader-name">{user.name}</span>
{user.email ? <span className="apg-info__uploader-email">{user.email}</span> : null}
</div>
</div>
</div>
);
}
/**
* Click-to-edit metadata field (caption / note). Saves on blur or Enter (Cmd/Ctrl+
* Enter for the multi-line note) via the supplied `onSave`, which persists through
* `updateMedia`. Shows an unobtrusive "edited" hint once a value is present.
*/
function EditableField({
label,
value,
placeholder,
multiline,
onSave,
}: {
label: string;
value?: string;
placeholder: string;
multiline?: boolean;
onSave: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
// Re-sync the draft when the underlying value changes and we're not editing.
useEffect(() => {
if (!editing) setDraft(value ?? '');
}, [value, editing]);
const commit = () => {
setEditing(false);
const next = draft.trim();
if (next !== (value ?? '').trim()) onSave(next);
};
const hasValue = Boolean((value ?? '').trim());
if (editing) {
const shared = {
className: 'apg-editable__input',
value: draft,
autoFocus: true,
placeholder,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setDraft(e.target.value),
onBlur: commit,
};
return (
<div className="apg-editable apg-editable--editing">
<span className="apg-editable__label">{label}</span>
{multiline ? (
<textarea
{...shared}
rows={3}
maxLength={4000}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') commit();
else if (e.key === 'Escape') {
setDraft(value ?? '');
setEditing(false);
}
}}
/>
) : (
<input
{...shared}
maxLength={280}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
else if (e.key === 'Escape') {
setDraft(value ?? '');
setEditing(false);
}
}}
/>
)}
</div>
);
}
return (
<button type="button" className="apg-editable" onClick={() => setEditing(true)}>
<span className="apg-editable__label">
{label}
{hasValue ? <span className="apg-editable__edited">edited</span> : null}
<Icon name="pencil" size={12} />
</span>
<span className={['apg-editable__value', hasValue ? '' : 'apg-editable__value--empty'].filter(Boolean).join(' ')}>
{hasValue ? value : placeholder}
</span>
</button>
);
}
@@ -18,6 +18,12 @@ export function closeModal() {
emit();
}
/** Whether a modal is currently mounted. Read by global Escape handling so the
* modal (which owns Escape) is never fought over by another handler. */
export function isModalOpen(): boolean {
return current !== null;
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
@@ -9,13 +9,14 @@ 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, MediaItem, ThemePreference } from '../types';
import type { Album, GalleryUser, MediaItem, ThemePreference, ViewId } from '../types';
import { AIAnalyzer } from './AIAnalyzer';
import { SemanticSearch } from './SemanticSearch';
import { AIProviderContext } from './aiContext';
@@ -44,9 +45,42 @@ export interface PhotoGalleryProps {
/** Per-theme color / gradient / radius overrides (mapped to CSS variables). */
themeTokens?: ThemeTokens;
features?: Partial<GalleryFeatures>;
/** Render the macOS-style traffic-light title bar. */
/** 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;
@@ -65,6 +99,14 @@ export function PhotoGallery(props: PhotoGalleryProps) {
features,
showWindowChrome = false,
title = 'Photos',
currentUser,
shareBaseUrl,
chrome,
hiddenViews,
keyboardShortcuts = true,
embedded = false,
lockProvider,
defaultFullscreen = false,
className,
style,
onReady,
@@ -80,16 +122,67 @@ export function PhotoGallery(props: PhotoGalleryProps) {
[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,
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, showWindowChrome, title],
[
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.
@@ -109,6 +202,12 @@ export function PhotoGallery(props: PhotoGalleryProps) {
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
@@ -138,7 +237,9 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
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.showWindowChrome);
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);
@@ -184,22 +285,73 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
const set = (v: string | undefined, name: string) => {
if (v && !CSS_UNSAFE.test(v)) tokenVars[name] = v;
};
const bg = dark ? tokens.bgDark : tokens.bgLight;
/** 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(dark ? tokens.elevatedDark : tokens.elevatedLight, '--apg-bg-elevated');
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 : dark ? tokens.sidebarBgDark : tokens.sidebarBgLight, '--apg-sidebar-bg');
set(dark ? tokens.textDark : tokens.textLight, '--apg-text');
if (typeof tokens.sidebarRadius === 'number') tokenVars['--apg-sidebar-radius'] = `${tokens.sidebarRadius}px`;
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,
};
@@ -207,7 +359,14 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
return (
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
<div
className={['apg', className].filter(Boolean).join(' ')}
className={[
'apg',
embedded ? 'apg--embedded' : '',
fullscreen ? 'apg--fullscreen' : '',
className,
]
.filter(Boolean)
.join(' ')}
data-theme={resolvedTheme}
style={rootStyle}
>
+61 -31
View File
@@ -116,8 +116,15 @@ export function Sidebar() {
const api = useGalleryStoreApi();
const sidebarOpen = useGallery((s) => s.sidebarOpen);
const albums = useGallery((s) => s.albums);
// Views the host asked to hide (e.g. Screenshots, Documents). A row is dropped
// when its `view` is listed; a section label is dropped when every row under it
// would be hidden.
const hiddenViews = useGallery((s) => s.config.hiddenViews);
const hidden = new Set<ViewId>(hiddenViews ?? []);
const shown = (view: ViewId) => !hidden.has(view);
const anyShown = (...views: ViewId[]) => views.some(shown);
// Recently Deleted lock state → closed lock when protected & not yet opened.
const locked = useGallery((s) => s.lock.hash !== null && !s.lockUnlocked);
const locked = useGallery((s) => s.lockConfigured && !s.lockUnlocked);
const lockIcon: IconName = locked ? 'lock' : 'unlock';
const [sharingOpen, setSharingOpen] = useState(true);
const [albumsOpen, setAlbumsOpen] = useState(true);
@@ -126,7 +133,7 @@ export function Sidebar() {
// Sidebar lock toggle: no password → set one; unlocked → re-lock; locked → go unlock.
const toggleLock = () => {
const s = api.getState();
if (!s.lock.hash) openSecuritySettings();
if (!s.lockConfigured) openSecuritySettings();
else if (s.lockUnlocked) s.relock();
else s.setView('recently-deleted');
};
@@ -207,22 +214,41 @@ export function Sidebar() {
<Row icon="library" label="Library" view="library" />
<Row icon="collections" label="Collections" view="collections" />
<SectionLabel>Pinned</SectionLabel>
<Row icon="heart" label="Favourites" view="favourites" />
<Row icon="download" label="Recently Saved" view="recently-saved" />
<Row icon="map" label="Map" view="map" />
<Row icon="video" label="Videos" view="videos" />
<Row icon="screenshot" label="Screenshots" view="screenshots" />
<Row icon="document" label="Documents" view="sys:documents" />
<Row icon="person-circle" label="People" view="people" />
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
{anyShown(
'favourites',
'recently-saved',
'map',
'videos',
'screenshots',
'sys:documents',
'people',
'recently-deleted',
) ? (
<SectionLabel>Pinned</SectionLabel>
) : null}
{shown('favourites') ? <Row icon="heart" label="Favourites" view="favourites" /> : null}
{shown('recently-saved') ? (
<Row icon="download" label="Recently Saved" view="recently-saved" />
) : null}
{shown('map') ? <Row icon="map" label="Map" view="map" /> : null}
{shown('videos') ? <Row icon="video" label="Videos" view="videos" /> : null}
{shown('screenshots') ? (
<Row icon="screenshot" label="Screenshots" view="screenshots" />
) : null}
{shown('sys:documents') ? (
<Row icon="document" label="Documents" view="sys:documents" />
) : null}
{shown('people') ? <Row icon="person-circle" label="People" view="people" /> : null}
{shown('recently-deleted') ? (
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
) : null}
<div
onContextMenu={(e) => {
@@ -304,19 +330,23 @@ export function Sidebar() {
/>
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
<SectionLabel>Utilities</SectionLabel>
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
noActive
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
<Row icon="duplicates" label="Duplicates" view="duplicates" />
<Row icon="clock" label="Versions & Audit" view="versions" />
<Row icon="map" label="Map" view="map" noActive />
{anyShown('recently-deleted', 'duplicates', 'versions', 'map') ? (
<SectionLabel>Utilities</SectionLabel>
) : null}
{shown('recently-deleted') ? (
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
noActive
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
) : null}
{shown('duplicates') ? <Row icon="duplicates" label="Duplicates" view="duplicates" /> : null}
{shown('versions') ? <Row icon="clock" label="Versions & Audit" view="versions" /> : null}
{shown('map') ? <Row icon="map" label="Map" view="map" noActive /> : null}
</nav>
);
}
@@ -52,6 +52,12 @@ const GRID_VIEWS = new Set<string>([
'search',
]);
const SCALE_OPTIONS: Array<{ value: LibraryScale; label: string }> = [
{ value: 'years', label: 'Years' },
{ value: 'months', label: 'Months' },
{ value: 'all', label: 'All Photos' },
];
const FILTER_OPTIONS: Array<{ value: GridFilter; label: string }> = [
{ value: 'all', label: 'All Items' },
{ value: 'favourites', label: 'Favourites' },
@@ -72,8 +78,11 @@ export function TopToolbar() {
const zoomIndex = useGallery((s) => s.zoomIndex);
const features = useGallery((s) => s.config.features);
const theme = useGallery((s) => s.theme);
// A host that owns light/dark must not be overridden from inside the gallery.
const showThemeSwitcher = useGallery((s) => s.config.chrome.themeSwitcher);
const aiStatus = useGallery((s) => s.aiStatus);
const infoOpen = useGallery((s) => s.infoOpen);
const fullscreen = useGallery((s) => s.fullscreen);
const selectionCount = useGallery((s) => s.selection.size);
const [searchExpanded, setSearchExpanded] = useState(false);
@@ -96,6 +105,23 @@ export function TopToolbar() {
);
};
// Library-scale as a compact dropdown (replaces the wide 3-item segmented that
// overlapped the zoom-out button on narrow toolbars). Reuses the `.apg-menu`
// popover, which supplies the menu role + checkmarks + arrow-key navigation.
const scaleLabel = SCALE_OPTIONS.find((o) => o.value === libraryScale)?.label ?? 'All Photos';
const openScaleMenu = (e: React.MouseEvent) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
openContextMenu(
r.left,
r.bottom + 4,
SCALE_OPTIONS.map((o) => ({
label: o.label,
checked: libraryScale === o.value,
onClick: () => api.getState().setLibraryScale(o.value),
})),
);
};
const openMoreMenu = (e: React.MouseEvent) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const state = api.getState();
@@ -116,21 +142,25 @@ export function TopToolbar() {
...(features.import
? [{ label: 'Import…', icon: 'download' as const, onClick: () => openUploadModal(currentAlbumId) }]
: []),
{ type: 'separator' as const },
{ type: 'label' as const, label: 'Appearance' },
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
{
label: 'Semi-Dark (Glass)',
icon: 'adjust' as const,
checked: theme === 'semi-dark',
onClick: () => state.setTheme('semi-dark'),
},
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
...(showThemeSwitcher
? [
{ type: 'separator' as const },
{ type: 'label' as const, label: 'Appearance' },
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
{
label: 'Semi-Dark (Glass)',
icon: 'adjust' as const,
checked: theme === 'semi-dark',
onClick: () => state.setTheme('semi-dark'),
},
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
]
: []),
{ type: 'separator' as const },
{
label: state.lock.hash ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
icon: state.lock.hash ? 'lock' : 'unlock',
label: state.lockConfigured ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
icon: state.lockConfigured ? 'lock' : 'unlock',
onClick: () => openSecuritySettings(),
},
...(view === 'recently-deleted'
@@ -195,18 +225,22 @@ export function TopToolbar() {
</button>
) : null}
{/* Center segmented control (context dependent). */}
{/* Center control (context dependent). Flanked by two flex spacers so it centres on
wide toolbars and simply sits in flow (never overlapping the right group) when the
toolbar is narrow e.g. embedded, where the host + gallery sidebars eat the width. */}
<div className="apg-toolbar__spacer" />
<div className="apg-toolbar__center">
{view === 'library' || view === 'search' ? (
<Segmented<LibraryScale>
value={libraryScale}
onChange={(v) => api.getState().setLibraryScale(v)}
options={[
{ value: 'years', label: 'Years' },
{ value: 'months', label: 'Months' },
{ value: 'all', label: 'All Photos' },
]}
/>
<button
type="button"
className="apg-scalemenu"
aria-haspopup="menu"
aria-label={`Library scale: ${scaleLabel}`}
onClick={openScaleMenu}
>
<span className="apg-scalemenu__label">{scaleLabel}</span>
<Icon name="chevron-down" size={14} />
</button>
) : view === 'map' ? (
<Segmented<MapMode>
value={mapMode}
@@ -267,6 +301,16 @@ export function TopToolbar() {
<button type="button" className="apg-iconbtn" aria-label="More" onClick={openMoreMenu}>
<Icon name="ellipsis" />
</button>
<button
type="button"
className={['apg-iconbtn', fullscreen ? 'apg-iconbtn--on' : ''].join(' ')}
aria-label={fullscreen ? 'Exit full screen' : 'Enter full screen'}
title={fullscreen ? 'Exit full screen' : 'Enter full screen'}
aria-pressed={fullscreen}
onClick={() => api.getState().toggleFullscreen()}
>
<Icon name={fullscreen ? 'minimize' : 'maximize'} />
</button>
<button
type="button"
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
@@ -295,7 +339,9 @@ export function TopToolbar() {
<Icon name="search" size={16} />
<input
aria-label="Search"
placeholder="Search"
// Discoverability only: `searchMedia` already matches objectLabels,
// OCR text, tags, captions and place names.
placeholder="Search photos, objects, text…"
value={searchQuery}
onFocus={() => setSearchExpanded(true)}
onChange={(e) => {
@@ -93,6 +93,7 @@ export function VideoEditor() {
const [annColor, setAnnColor] = useState('#ff3b30');
const [duration, setDuration] = useState(0);
const [playhead, setPlayhead] = useState(0);
const [playing, setPlaying] = useState(false);
const [selOverlay, setSelOverlay] = useState<string | null>(null);
const provider = useAIProvider();
const [denoiseBusy, setDenoiseBusy] = useState(false);
@@ -141,8 +142,18 @@ export function VideoEditor() {
const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration));
if (v.currentTime >= hi) v.currentTime = lo;
};
// The preview <video> has no native controls (they would rotate with the frame),
// so the custom control bar drives it — keep its play/pause icon in sync here.
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
v.addEventListener('timeupdate', onTime);
return () => v.removeEventListener('timeupdate', onTime);
v.addEventListener('play', onPlay);
v.addEventListener('pause', onPause);
return () => {
v.removeEventListener('timeupdate', onTime);
v.removeEventListener('play', onPlay);
v.removeEventListener('pause', onPause);
};
}, [edits.trim, edits.segments, duration]);
useEffect(() => {
@@ -398,9 +409,12 @@ export function VideoEditor() {
<video
ref={videoRef}
src={item.src}
controls
playsInline
crossOrigin="anonymous"
onClick={() => {
const v = videoRef.current;
if (v) (v.paused ? v.play() : v.pause());
}}
style={{
maxWidth: '100%',
maxHeight: '70vh',
@@ -476,6 +490,39 @@ export function VideoEditor() {
onChange={(annotations) => update({ annotations })}
/>
</div>
{/* Custom transport OUTSIDE the transformed <video>, so rotate/flip/crop
only affect the frame, never the controls (the old native `controls`
bar rotated with the video, which looked broken). */}
<div className="apg-vedit__transport">
<button
type="button"
className="apg-iconbtn"
aria-label={playing ? 'Pause' : 'Play'}
onClick={() => {
const v = videoRef.current;
if (v) (v.paused ? v.play() : v.pause());
}}
>
<Icon name={playing ? 'pause' : 'play'} size={18} />
</button>
<input
type="range"
className="apg-vedit__scrub"
min={0}
max={duration || 0}
step={0.01}
value={Math.min(playhead, duration || 0)}
aria-label="Seek"
onChange={(e) => {
const v = videoRef.current;
if (v) v.currentTime = Number(e.target.value);
}}
/>
<span className="apg-vedit__time">
{fmt(playhead)} / {fmt(duration)}
</span>
</div>
</div>
<div className="apg-editor__panel apg-scroll">
+39 -9
View File
@@ -184,19 +184,39 @@ export function confirmAction(opts: {
function SecuritySettingsModal() {
const api = useGalleryStoreApi();
const hasPw = useGallery((s) => s.lock.hash !== null);
const hasPw = useGallery((s) => s.lockConfigured);
// A server-backed lock reads/writes the host's backend, not this device.
const serverBacked = useGallery((s) => Boolean(s.config.lockProvider));
const [p1, setP1] = useState('');
const [p2, setP2] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const save = async () => {
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
if (p1 !== p2) return setErr('Passwords do not match.');
await api.getState().setLockPassword(p1.trim());
setBusy(true);
const state = api.getState();
state.clearLockError();
await state.setLockPassword(p1.trim());
setBusy(false);
// The store reports an unreachable provider rather than throwing.
if (api.getState().lockError === 'unavailable') {
setErr("Couldn't reach the server. Try again.");
return;
}
closeModal();
};
const remove = () => {
api.getState().removeLockPassword();
const remove = async () => {
setBusy(true);
const state = api.getState();
state.clearLockError();
await state.removeLockPassword();
setBusy(false);
if (api.getState().lockError === 'unavailable') {
setErr("Couldn't reach the server. Try again.");
return;
}
closeModal();
};
@@ -206,9 +226,13 @@ function SecuritySettingsModal() {
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
</div>
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
{hasPw
? 'Set a new password, or remove the lock. The password is stored only on this device.'
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
{serverBacked
? hasPw
? 'Set a new password, or remove the lock. It applies to your account on every device.'
: 'Protect Recently Deleted with a password. It applies to your account on every device.'
: hasPw
? 'Set a new password, or remove the lock. The password is stored only on this device.'
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
</div>
<input
className="apg-modal__input"
@@ -241,7 +265,8 @@ function SecuritySettingsModal() {
type="button"
className="apg-btn"
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
onClick={remove}
disabled={busy}
onClick={() => void remove()}
>
Remove Lock
</button>
@@ -249,7 +274,12 @@ function SecuritySettingsModal() {
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save()}>
<button
type="button"
className="apg-btn apg-btn--primary"
disabled={busy}
onClick={() => void save()}
>
{hasPw ? 'Change' : 'Set Password'}
</button>
</div>
@@ -1,7 +1,5 @@
'use client';
import { useState } from 'react';
import { formatDay } from '../../lib/format';
import { groupByTime } from '../../lib/grouping';
import { resolveLabel } from '../../lib/smartAlbums';
@@ -102,12 +100,10 @@ export function CollectionsView() {
const labelAliases = useGallery((s) => s.labelAliases);
const live = liveMedia(media);
const first = (pred: (m: MediaItem) => boolean) => live.find(pred);
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
const go = (v: ViewId) => () => api.getState().setView(v);
const recentDays = groupByTime(live, 'day').slice(0, 8);
const featured = [...live].sort((a, b) => b.takenAt - a.takenAt).slice(0, 12);
const objectEntries = [...objectLabelCounts(media, labelAliases).entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 14);
@@ -143,31 +139,6 @@ export function CollectionsView() {
return (
<div className="apg-scroll">
<div className="apg-collections">
{/* Memories */}
<section className="apg-collections__section">
<SectionHeader title="Memories" />
<EmptyCard
icon="clock"
title="No Memories Available"
text="Memories will appear here when more photos and videos are added to the library."
/>
</section>
{/* Pinned */}
<section className="apg-collections__section">
<SectionHeader title="Pinned" chevronTo={go('collections')} />
<div className="apg-pinned-row">
<PinnedCard label="Favourites" badge="heart-fill" cover={first((m) => m.favorite)} onClick={go('favourites')} />
<PinnedCard label="Recently Saved" cover={first((m) => m.source === 'download' || m.source === 'social')} onClick={go('recently-saved')} />
<PinnedCard label="Map" cover={first((m) => Boolean(m.location))} onClick={go('map')} />
<PinnedCard label="Videos" cover={first((m) => m.kind === 'video')} onClick={go('videos')} />
<PinnedCard label="Screenshots" cover={first((m) => m.source === 'screenshot')} onClick={go('screenshots')} />
<PinnedCard label="Documents" badge="document" cover={first((m) => Boolean(m.ocrText && m.ocrText.trim()))} onClick={go('sys:documents')} />
<PinnedCard label="People & Pets" onClick={go('people')} />
<PinnedCard label="Recently Deleted" badge="trash" onClick={go('recently-deleted')} />
</div>
</section>
{/* Albums */}
<section className="apg-collections__section">
<SectionHeader
@@ -236,34 +207,6 @@ export function CollectionsView() {
</section>
) : null}
{/* People & Pets */}
<section className="apg-collections__section">
<SectionHeader title="People & Pets" chevronTo={go('people')} />
<EmptyCard
icon="person-circle"
title="Finding People…"
text="Photos creates albums and groups of people and pets found in your library."
/>
</section>
{/* Featured Photos */}
<section className="apg-collections__section">
<SectionHeader title="Featured Photos" />
{featured.length === 0 ? (
<EmptyCard
icon="image"
title="No Featured Photos Available"
text="Featured Photos will appear here when more photos and videos are added."
/>
) : (
<div className="apg-pinned-row">
{featured.map((m) => (
<PinnedCard key={m.id} label="" cover={m} onClick={() => api.getState().openLightbox(m.id)} />
))}
</div>
)}
</section>
{/* Shared Albums */}
<section className="apg-collections__section">
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
@@ -297,36 +240,6 @@ export function CollectionsView() {
)}
</section>
{/* Trips */}
<section className="apg-collections__section">
<SectionHeader title="Trips" />
<EmptyCard
icon="suitcase"
title="No Trips Available"
text="Trips will appear here when more photos and videos are added to the library."
/>
</section>
{/* Utilities */}
<section className="apg-collections__section">
<SectionHeader title="Utilities" chevronTo={go('recently-deleted')} />
<button type="button" className="apg-list-row" onClick={go('recently-deleted')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
<Icon name="trash" size={18} />
Recently Deleted
<span className="apg-list-row__trail">
<Icon name="lock" size={15} />
</span>
</button>
<button type="button" className="apg-list-row" onClick={go('duplicates')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
<Icon name="duplicates" size={18} />
Duplicates
<span className="apg-list-row__trail">{live.length ? 0 : 0}</span>
</button>
<button type="button" className="apg-list-row" onClick={go('map')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
<Icon name="map" size={18} />
Map
</button>
</section>
</div>
</div>
);
@@ -10,16 +10,19 @@ import { liveMedia } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
function Welcome({ onImport }: { onImport: () => void }) {
// Host-neutral copy: the SDK is embedded in other products (see `title`), so the
// empty state must not name a specific photo app or reference its settings.
const hints: Array<{ icon: IconName; text: string }> = [
{ icon: 'camera', text: 'Connect a camera or memory card.' },
{ icon: 'duplicates', text: 'Drag pictures directly into Photos.' },
{ icon: 'download', text: 'Click the + button to import.' },
{ icon: 'image', text: 'Turn on iCloud Photos in Settings.' },
{ icon: 'duplicates', text: 'Drag photos and videos straight in.' },
{ icon: 'camera', text: 'Capture a shot with your camera.' },
{ icon: 'image', text: 'Everything you add is searchable.' },
];
const title = useGallery((s) => s.config.title);
return (
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
<div className="apg-empty__title">Welcome to Photos</div>
<div className="apg-empty__subtitle">To get started with Photos, do any of the following:</div>
<div className="apg-empty__title">Welcome to {title}</div>
<div className="apg-empty__subtitle">To get started, do any of the following:</div>
<div className="apg-empty__hints">
{hints.map((h, i) => (
<div className="apg-empty__hint" key={i}>
@@ -1,15 +1,185 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { MAP_TILES } from '../../constants';
import { groupByTime } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { clusterByLocation, type LocationCluster, liveMedia, locatedMedia } from '../../store/selectors';
import {
clusterByLocation,
type LocationCluster,
liveMedia,
locatedMedia,
searchMedia,
} from '../../store/selectors';
import type { MediaItem } from '../../types';
import { MediaGrid } from '../MediaGrid';
import { MosaicGrid } from './MosaicGrid';
/** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */
const TIP_STRIP_MAX = 5;
/** How long each frame of the hover mini-slider stays up. */
const TIP_FRAME_MS = 900;
/** Parse a `<input type="date">` value into a local-midnight epoch, or null. */
function parseDateInput(value: string, endOfDay: boolean): number | null {
if (!value) return null;
const [y, m, d] = value.split('-').map(Number);
if (!y || !m || !d) return null;
return endOfDay
? new Date(y, m - 1, d, 23, 59, 59, 999).getTime()
: new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
}
/** Format a Date to a `<input type="date">` value (local `YYYY-MM-DD`). */
function toInputDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Short human label for one `YYYY-MM-DD` value. */
function labelDate(value: string): string {
const [y, m, d] = value.split('-').map(Number);
if (!y || !m || !d) return '';
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/** Button label for the current range ("All dates" when unset). */
function rangeLabel(from: string, to: string): string {
if (!from && !to) return 'All dates';
if (from && to) return `${labelDate(from)} ${labelDate(to)}`;
if (from) return `From ${labelDate(from)}`;
return `Until ${labelDate(to)}`;
}
/**
* A single, polished "date range" control: a button showing the current range
* that opens a popover of quick presets + a custom From/To pair. It drives the
* SAME from/to strings the MapView already filters by so search/object chips,
* the live count, and Clear all keep working unchanged.
*/
function DateRangeControl({
from,
to,
onChange,
}: {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onDown, true);
window.removeEventListener('keydown', onKey);
};
}, [open]);
const today = new Date();
const daysAgo = (n: number) => {
const d = new Date();
d.setDate(today.getDate() - n);
return d;
};
const presets: Array<{ label: string; from: string; to: string }> = [
{ label: 'All dates', from: '', to: '' },
{ label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) },
{ label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) },
{
label: 'This year',
from: toInputDate(new Date(today.getFullYear(), 0, 1)),
to: toInputDate(today),
},
];
const active = Boolean(from || to);
return (
<div className="apg-daterange" ref={ref}>
<button
type="button"
className={['apg-daterange__button', active ? 'apg-daterange__button--active' : '']
.filter(Boolean)
.join(' ')}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<Icon name="clock" size={14} />
<span className="apg-daterange__label">{rangeLabel(from, to)}</span>
<Icon name="chevron-down" size={14} />
</button>
{open ? (
<div className="apg-daterange__pop" role="dialog" aria-label="Choose a date range">
<div className="apg-daterange__presets">
{presets.map((p) => {
const on = p.from === from && p.to === to;
return (
<button
key={p.label}
type="button"
className={['apg-daterange__preset', on ? 'apg-daterange__preset--on' : '']
.filter(Boolean)
.join(' ')}
aria-pressed={on}
onClick={() => {
onChange(p.from, p.to);
setOpen(false);
}}
>
{p.label}
{on ? <Icon name="check" size={14} /> : null}
</button>
);
})}
</div>
<div className="apg-daterange__custom">
<div className="apg-daterange__custom-label">Custom range</div>
<div className="apg-daterange__fields">
<label className="apg-daterange__field">
<span>From</span>
<input
type="date"
value={from}
max={to || undefined}
onChange={(e) => onChange(e.target.value, to)}
/>
</label>
<label className="apg-daterange__field">
<span>To</span>
<input
type="date"
value={to}
min={from || undefined}
onChange={(e) => onChange(from, e.target.value)}
/>
</label>
</div>
</div>
</div>
) : null}
</div>
);
}
export function MapView() {
const api = useGalleryStoreApi();
const mapMode = useGallery((s) => s.mapMode);
@@ -23,6 +193,19 @@ export function MapView() {
const [sheetH, setSheetH] = useState(0.5);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
// ---- sheet filters (search + date range + object chips), AND-combined ----
const [query, setQuery] = useState('');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [objectFilter, setObjectFilter] = useState<string | null>(null);
const resetFilters = () => {
setQuery('');
setFromDate('');
setToDate('');
setObjectFilter(null);
};
const onHandleDown = (e: React.PointerEvent) => {
dragRef.current = { startY: e.clientY, startH: sheetH };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -44,16 +227,54 @@ export function MapView() {
const tileRef = useRef<any>(null);
const markersRef = useRef<any>(null);
const leafletRef = useRef<any>(null);
// Every hover-slider interval currently running, so teardown can kill them all
// (a leaked interval keeps mutating detached DOM forever).
const tipTimersRef = useRef(new Set<ReturnType<typeof setInterval>>());
const clearTipTimers = () => {
for (const t of tipTimersRef.current) clearInterval(t);
tipTimersRef.current.clear();
};
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
// are created once, so closing over state directly would go stale).
const locatedRef = useRef(located);
locatedRef.current = located;
// Current sheet height in px, read by the fly-to offset (the sheet covers the
// bottom of the map, so the pin must end up above it).
const sheetHRef = useRef(sheetH);
sheetHRef.current = sheetH;
/** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
openClusterRef.current = (c) => {
const map = mapRef.current;
if (map) {
const current = map.getZoom?.() ?? 2;
// Never zoom OUT: a single photo warrants street level, a cluster stays wide
// enough that its members remain distinguishable.
const target = Math.max(current, c.items.length > 1 ? 12 : 14);
const size = map.getSize?.();
const mapH: number = size?.y ?? 0;
// The sheet is about to occupy the bottom `sheetH` of the map, so shift the
// centre up by half of that — the pin then sits in the visible upper band.
const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height
map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 });
if (sheetPx > 0) {
// panBy after the fly settles, else Leaflet cancels the in-flight animation.
// Leaflet's panBy moves the map pane BY the offset, so content shifts the
// opposite way: a POSITIVE y lifts the pin toward the top of the map,
// which is the direction that gets it clear of the sheet.
map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true }));
}
}
setCluster(c);
setSheetH(0.5); // reset to half height each time a pin is opened
resetFilters(); // a new pin starts with a clean filter
};
// Opening one photo from a pin's hover strip.
const openLightboxRef = useRef<(id: string) => void>(() => {});
openLightboxRef.current = (id) => api.getState().openLightbox(id);
// Create the map once when entering a map tile mode.
useEffect(() => {
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
@@ -91,16 +312,19 @@ export function MapView() {
// Tear the map down when leaving map tile modes.
useEffect(() => {
if (mapMode === 'grid' && mapRef.current) {
clearTipTimers();
mapRef.current.remove();
mapRef.current = null;
tileRef.current = null;
markersRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapMode]);
// Destroy on unmount.
useEffect(() => {
return () => {
clearTipTimers();
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
@@ -108,6 +332,7 @@ export function MapView() {
markersRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addTiles = () => {
@@ -131,6 +356,8 @@ export function MapView() {
if (!L || !map) return;
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
// Every marker is about to be destroyed — kill their hover timers first.
clearTipTimers();
markersRef.current.clearLayers();
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
@@ -140,16 +367,19 @@ export function MapView() {
for (const c of clusters) {
const cover = c.items[0]!;
const multi = c.items.length > 1;
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
// injected; the thumbnail src + count are set via DOM after the marker mounts.
const icon = L.divIcon({
className: 'apg-pin-wrap',
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
// hover tooltip with an image preview + caption.
// hover tooltip: one preview for a single photo, an auto-advancing strip
// of thumbnails for a cluster.
html:
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
'<span class="apg-pin__count"></span>' +
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
'<span class="apg-pin__strip"></span>' +
'<span class="apg-pin__tip-cap"></span></span></span>',
iconSize: [56, 64],
iconAnchor: [28, 64],
@@ -162,8 +392,30 @@ export function MapView() {
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
if (img) img.src = thumb;
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
if (tipImg) tipImg.src = thumb;
const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null;
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
// Keep the hover tooltip inside the map: on enter, measure it against the map
// container and slide it horizontally (--tip-dx) / flip it below (--below) so it
// never overflows and gets clipped by the gallery's rounded, overflow-hidden shell.
const pinEl = el.querySelector('.apg-pin') as HTMLElement | null;
const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null;
const clampTip = () => {
if (!tipEl) return;
const mapEl = el.closest('.leaflet-container') as HTMLElement | null;
if (!mapEl) return;
tipEl.style.setProperty('--tip-dx', '0px');
tipEl.classList.remove('apg-pin__tip--below');
const m = mapEl.getBoundingClientRect();
const t = tipEl.getBoundingClientRect();
const pad = 8;
let dx = 0;
if (t.left < m.left + pad) dx = m.left + pad - t.left;
else if (t.right > m.right - pad) dx = m.right - pad - t.right;
if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`);
if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below');
};
pinEl?.addEventListener('mouseenter', clampTip);
if (cap) {
// textContent (never innerHTML) — place names are attacker-influencable.
const place = cover.location?.place ?? '';
@@ -172,9 +424,81 @@ export function MapView() {
}
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
if (badge) {
// The badge always carries the cluster's TOTAL, and only appears when
// there is genuinely more than one photo here.
badge.textContent = String(c.items.length);
if (c.items.length < 2) badge.style.display = 'none';
badge.style.display = multi ? '' : 'none';
}
if (!multi || !strip) {
if (tipImg) tipImg.src = thumb;
return;
}
// ---- multi-photo pin: horizontal strip + auto-advancing preview ----
if (tipImg) tipImg.src = thumb;
const shown = c.items.slice(0, TIP_STRIP_MAX);
const cells: HTMLElement[] = [];
for (const item of shown) {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'apg-pin__strip-cell';
cell.title = item.name;
const thumbEl = document.createElement('img');
thumbEl.alt = '';
thumbEl.src = item.thumbnail ?? item.src;
cell.appendChild(thumbEl);
// Opening a specific photo must not also open the cluster sheet.
cell.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
openLightboxRef.current(item.id);
});
strip.appendChild(cell);
cells.push(cell);
}
if (c.items.length > TIP_STRIP_MAX) {
const more = document.createElement('span');
more.className = 'apg-pin__strip-more';
more.textContent = `+${c.items.length - TIP_STRIP_MAX}`;
strip.appendChild(more);
}
// Auto-advance the big preview (and the highlighted cell) while hovered.
const pin = el.querySelector('.apg-pin') as HTMLElement | null;
if (!pin) return;
let timer: ReturnType<typeof setInterval> | null = null;
let frame = 0;
const paint = () => {
const item = c.items[frame % c.items.length]!;
if (tipImg) tipImg.src = item.thumbnail ?? item.src;
cells.forEach((cell, i) =>
cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length),
);
};
const stop = () => {
if (timer === null) return;
clearInterval(timer);
tipTimersRef.current.delete(timer);
timer = null;
frame = 0;
if (tipImg) tipImg.src = thumb;
cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on'));
};
const start = () => {
if (timer !== null) return;
frame = 0;
paint();
timer = setInterval(() => {
frame += 1;
paint();
}, TIP_FRAME_MS);
tipTimersRef.current.add(timer);
};
pin.addEventListener('mouseenter', start);
pin.addEventListener('mouseleave', stop);
// Leaflet destroys the element on clearLayers(); `remove` fires first.
marker.on('remove', stop);
});
marker.on('click', () => openClusterRef.current(c));
markersRef.current.addLayer(marker);
@@ -200,6 +524,36 @@ export function MapView() {
}
}, [mapFocus]);
// Distinct object labels present in the open cluster, most common first.
const clusterObjects = useMemo(() => {
if (!cluster) return [] as Array<{ label: string; count: number }>;
const counts = new Map<string, number>();
for (const m of cluster.items) {
for (const label of new Set(m.objectLabels)) {
counts.set(label, (counts.get(label) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}, [cluster]);
// Search + date range + object chip, combined with AND.
const filtered = useMemo<MediaItem[]>(() => {
if (!cluster) return [];
let items = cluster.items;
if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter));
const from = parseDateInput(fromDate, false);
const to = parseDateInput(toDate, true);
if (from !== null) items = items.filter((m) => m.takenAt >= from);
if (to !== null) items = items.filter((m) => m.takenAt <= to);
// Same matcher as the global search bar, so "car" means the same thing here.
if (query.trim()) items = searchMedia(items, query);
return items;
}, [cluster, query, fromDate, toDate, objectFilter]);
const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter);
if (mapMode === 'grid') {
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
// located ones) so it's a rich date-grouped collage, per the design.
@@ -275,8 +629,12 @@ export function MapView() {
<div style={{ fontWeight: 700, fontSize: 16 }}>
{cluster.items[0]?.location?.place ?? 'This location'}
</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
{cluster.items.length} photo{cluster.items.length === 1 ? '' : 's'}
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }} aria-live="polite">
{filterActive
? `${filtered.length} of ${cluster.items.length} photo${
cluster.items.length === 1 ? '' : 's'
}`
: `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
</div>
</div>
<button
@@ -288,10 +646,85 @@ export function MapView() {
<Icon name="close" />
</button>
</div>
{/* Search + date range + object chips — all AND-combined. */}
<div className="apg-map__sheet-filters">
<div className="apg-mapfilter__row">
<div className="apg-mapfilter__search">
<Icon name="search" size={15} />
<input
type="search"
aria-label="Search photos at this location"
placeholder="Search photos, objects, text…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{filterActive ? (
<button
type="button"
className="apg-mapfilter__clear"
aria-label="Clear filters"
title="Clear filters"
onClick={resetFilters}
>
<Icon name="close" size={13} />
</button>
) : null}
</div>
</div>
<div className="apg-mapfilter__row apg-mapfilter__dates">
<DateRangeControl
from={fromDate}
to={toDate}
onChange={(f, t) => {
setFromDate(f);
setToDate(t);
}}
/>
</div>
{clusterObjects.length ? (
<div className="apg-mapfilter__chips" role="group" aria-label="Filter by object">
{clusterObjects.map(({ label, count }) => {
const on = objectFilter === label;
return (
<button
key={label}
type="button"
className={[
'apg-mapfilter__chip',
on ? 'apg-mapfilter__chip--on' : '',
]
.filter(Boolean)
.join(' ')}
aria-pressed={on}
onClick={() => setObjectFilter(on ? null : label)}
>
{label}
<span className="apg-mapfilter__chip-n">{count}</span>
</button>
);
})}
</div>
) : null}
</div>
<div className="apg-map__sheet-body apg-scroll">
{groupByTime(cluster.items, 'day').map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))}
{filtered.length === 0 ? (
<div className="apg-mapfilter__empty">
<Icon name="search" size={26} />
<div className="apg-mapfilter__empty-title">No matching photos</div>
<div className="apg-mapfilter__empty-sub">
Try a different search, date range, or object.
</div>
<button type="button" className="apg-btn" onClick={resetFilters}>
Clear filters
</button>
</div>
) : (
groupByTime(filtered, 'day').map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))
)}
</div>
</div>
) : null}
@@ -20,8 +20,8 @@ export function PeopleView() {
Finding People
</div>
<div className="apg-empty__subtitle">
Photos creates albums and groups of people and pets found in your library when you are
not using the app.
People and pets are grouped automatically as photos are analyzed. Add more photos and
they will appear here.
</div>
</div>
</div>
@@ -13,16 +13,18 @@ import { openSecuritySettings } from '../modals';
export function RecentlyDeletedView() {
const api = useGalleryStoreApi();
const items = useViewMedia();
const lockHash = useGallery((s) => s.lock.hash);
const lockConfigured = useGallery((s) => s.lockConfigured);
const lockUnlocked = useGallery((s) => s.lockUnlocked);
const lockError = useGallery((s) => s.lockError);
const [pw, setPw] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
// Locked: a password is set and the user hasn't unlocked this session.
if (lockHash && !lockUnlocked) {
if (lockConfigured && !lockUnlocked) {
const submit = async () => {
const ok = await api.getState().unlockLock(pw);
if (!ok) setError(true);
setBusy(true);
await api.getState().unlockLock(pw);
setBusy(false);
setPw('');
};
return (
@@ -42,17 +44,26 @@ export function RecentlyDeletedView() {
style={{ maxWidth: 280 }}
onChange={(e) => {
setPw(e.target.value);
setError(false);
api.getState().clearLockError();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
}}
/>
{error ? (
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>Incorrect password.</div>
{lockError ? (
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }} role="alert">
{lockError === 'wrong-password'
? 'Incorrect password.'
: "Couldn't check the password. Please try again."}
</div>
) : null}
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void submit()}>
Unlock
<button
type="button"
className="apg-btn apg-btn--primary"
disabled={busy}
onClick={() => void submit()}
>
{busy ? 'Checking…' : 'Unlock'}
</button>
</div>
);
@@ -107,8 +118,8 @@ export function RecentlyDeletedView() {
style={{ flexShrink: 0, padding: '4px 10px' }}
onClick={openSecuritySettings}
>
<Icon name={lockHash ? 'lock' : 'unlock'} size={13} />
{lockHash ? 'Locked' : 'Lock…'}
<Icon name={lockConfigured ? 'lock' : 'unlock'} size={13} />
{lockConfigured ? 'Locked' : 'Lock…'}
</button>
</div>
<MediaGrid items={items} />
@@ -75,6 +75,7 @@ export function VersionsView() {
<span className="apg-audit-card__latest">
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
{formatTime(latest.createdAt)}
{latest.author ? ` · ${latest.author}` : ''}
</span>
) : null}
</span>
@@ -2,14 +2,22 @@
import { useEffect } from 'react';
import { isContextMenuOpen } from '../components/ContextMenu';
import { isModalOpen } from '../components/Modal';
import { useGalleryStoreApi } from '../store/context';
import { mediaForView } from '../store/selectors';
/** Wire Apple-Photos-style global keyboard shortcuts. */
export function useKeyboardShortcuts() {
/**
* Wire Apple-Photos-style global keyboard shortcuts.
*
* `enabled` is a parameter (not a call-site condition) so hook order stays
* stable: an embedding host that owns its own key handling passes `false`.
*/
export function useKeyboardShortcuts(enabled: boolean = true) {
const api = useGalleryStoreApi();
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
// Never hijack typing in inputs / editable fields.
@@ -50,10 +58,20 @@ export function useKeyboardShortcuts() {
return;
}
// Escape — clear selection / focus.
// Escape — clear selection / focus, then leave full screen.
if (e.key === 'Escape') {
// Anything layered above the shell owns Escape first: the lightbox,
// editors, the camera, modals and context menus each close themselves.
const overlayOpen =
Boolean(state.lightboxId) ||
Boolean(state.editorId) ||
state.cameraOpen ||
isModalOpen() ||
isContextMenuOpen();
if (overlayOpen) return;
if (state.objectFocus) state.setObjectFocus(null);
else if (state.selection.size) state.clearSelection();
else if (state.fullscreen) state.setFullscreen(false);
}
// Enter / Space — open the (single) selection in the lightbox.
@@ -65,5 +83,5 @@ export function useKeyboardShortcuts() {
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
}, [api, enabled]);
}
+27 -2
View File
@@ -1,6 +1,6 @@
'use client';
import type { SVGProps } from 'react';
import type { ReactElement, SVGProps } from 'react';
/**
* Original SF-symbol-inspired icon set drawn from scratch (no Apple assets).
@@ -52,10 +52,13 @@ export type IconName =
| 'volume'
| 'mute'
| 'expand'
| 'maximize'
| 'minimize'
| 'pip'
| 'tag'
| 'document'
| 'pin'
| 'pencil'
| 'mic';
export interface IconProps extends SVGProps<SVGSVGElement> {
@@ -83,7 +86,9 @@ export function Icon({ name, size = 20, ...rest }: IconProps) {
);
}
const paths: Record<IconName, JSX.Element> = {
// `ReactElement`, not the global `JSX.Element`: React 19 removed the global JSX
// namespace, so a React-19 host compiling this source would fail to resolve it.
const paths: Record<IconName, ReactElement> = {
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
@@ -91,6 +96,12 @@ const paths: Record<IconName, JSX.Element> = {
<path d="M12 17v3M9 20.5h6" />
</>
),
pencil: (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</>
),
library: (
<>
<rect x="3" y="6" width="18" height="13" rx="2.5" />
@@ -321,6 +332,20 @@ const paths: Record<IconName, JSX.Element> = {
expand: (
<path d="M9 4H5a1 1 0 0 0-1 1v4m11-5h4a1 1 0 0 1 1 1v4M9 20H5a1 1 0 0 1-1-1v-4m11 5h4a1 1 0 0 0 1-1v-4" />
),
/* Four corner brackets pushing outward — "enter full screen". */
maximize: (
<>
<path d="M4 9.5V5.5A1.5 1.5 0 0 1 5.5 4h4M14.5 4h4A1.5 1.5 0 0 1 20 5.5v4M20 14.5v4a1.5 1.5 0 0 1-1.5 1.5h-4M9.5 20h-4A1.5 1.5 0 0 1 4 18.5v-4" />
<path d="m4.8 4.8 4 4M19.2 4.8l-4 4M19.2 19.2l-4-4M4.8 19.2l4-4" opacity="0.5" />
</>
),
/* Same brackets pulled inward — "exit full screen". */
minimize: (
<>
<path d="M9.5 4v4a1.5 1.5 0 0 1-1.5 1.5H4M20 9.5h-4A1.5 1.5 0 0 1 14.5 8V4M14.5 20v-4a1.5 1.5 0 0 1 1.5-1.5h4M4 14.5h4a1.5 1.5 0 0 1 1.5 1.5v4" />
<path d="m4.8 4.8 3.4 3.4M19.2 4.8l-3.4 3.4M19.2 19.2l-3.4-3.4M4.8 19.2l3.4-3.4" opacity="0.5" />
</>
),
pip: (
<>
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
+14 -3
View File
@@ -9,14 +9,16 @@ export type { PhotoGalleryProps } from './components/PhotoGallery';
// Headless store access (for advanced composition / external control)
export { useGallery, useGalleryStoreApi, GalleryStoreContext } from './store/context';
export { createGalleryStore, DEFAULT_FEATURES } from './store/store';
export { createGalleryStore, DEFAULT_FEATURES, DEFAULT_CHROME } from './store/store';
export type {
GalleryState,
GalleryStore,
GalleryConfig,
GalleryFeatures,
GalleryChrome,
ThemeTokens,
GridFilter,
LockProvider,
} from './store/store';
export * as selectors from './store/selectors';
@@ -26,6 +28,10 @@ export type {
MediaId,
MediaKind,
MediaSource,
MediaComment,
MediaVersion,
ShareRecord,
GalleryUser,
Album,
AlbumId,
AlbumKind,
@@ -49,10 +55,15 @@ export type {
// Storage adapters
export { createLocalStorageAdapter } from './adapters/localStorage';
export type { StorageAdapter, PersistedState } from './adapters/types';
export type {
StorageAdapter,
PersistedState,
StateChanges,
StoredBlob,
} from './adapters/types';
// AI provider interface (pluggable; free in-browser or cloud)
export type { AIProvider, GenerativeEditOp } from './ai/types';
export type { AIProvider, GenerativeEditOp, TiltEstimate } from './ai/types';
export { cosineSimilarity } from './ai/types';
// Helpers
+22 -1
View File
@@ -49,6 +49,10 @@ export function createMediaItem(input: MediaInput): MediaItem {
personIds: input.personIds ?? [],
location: input.location,
exif: input.exif,
// Who captured/imported this (display identity) + user note — preserved through
// the single normalization path so they survive a reload.
uploadedBy: input.uploadedBy,
note: input.note,
caption: input.caption,
objects: input.objects,
faces: input.faces,
@@ -109,7 +113,23 @@ export function normalizeMediaItem(raw: unknown): MediaItem | null {
const lat = asFiniteNumber(loc.lat);
const lng = asFiniteNumber(loc.lng);
if (lat !== undefined && lng !== undefined) {
location = { lat, lng, place: asString(loc.place), city: asString(loc.city), country: asString(loc.country) };
// Preserve every reverse-geocoded address field so a persisted full address
// survives a reload (the InfoPanel then won't re-fetch it).
location = {
lat,
lng,
place: asString(loc.place),
road: asString(loc.road),
neighbourhood: asString(loc.neighbourhood),
suburb: asString(loc.suburb),
city: asString(loc.city),
county: asString(loc.county),
state: asString(loc.state),
postcode: asString(loc.postcode),
country: asString(loc.country),
countryCode: asString(loc.countryCode),
formatted: asString(loc.formatted),
};
}
}
@@ -120,6 +140,7 @@ export function normalizeMediaItem(raw: unknown): MediaItem | null {
poster: safeMediaUrl(r.poster),
name: asString(r.name),
caption: asString(r.caption),
note: asString(r.note),
tags: asStringArray(r.tags),
objectLabels: asStringArray(r.objectLabels),
albumIds: asStringArray(r.albumIds),
@@ -55,9 +55,13 @@ export function searchMedia(items: MediaItem[], query: string): MediaItem[] {
m.source,
m.kind,
m.caption ?? '',
m.note ?? '',
m.ocrText ?? '',
m.location?.place ?? '',
m.location?.formatted ?? '',
m.location?.city ?? '',
m.location?.state ?? '',
m.location?.postcode ?? '',
m.location?.country ?? '',
...m.tags,
...m.objectLabels,
+493 -22
View File
@@ -1,7 +1,7 @@
import { nanoid } from 'nanoid';
import { createStore } from 'zustand/vanilla';
import type { StorageAdapter } from '../adapters/types';
import type { StateChanges, StorageAdapter } from '../adapters/types';
import type { AIProvider } from '../ai/types';
import { DEFAULT_ZOOM_INDEX, GRID_ZOOM_STEPS, PET_LABELS, TRASH_RETENTION_MS } from '../constants';
import { clusterFaces } from '../lib/cluster';
@@ -12,6 +12,7 @@ import type {
Album,
AlbumId,
EditState,
GalleryUser,
LibraryScale,
MapMode,
MediaComment,
@@ -61,24 +62,131 @@ export const DEFAULT_FEATURES: GalleryFeatures = {
* (content), dark values in dark mode (and the semi-dark sidebar).
*/
export interface ThemeTokens {
/** Main app / content background. */
/** Main app / content background. → `--apg-bg` + `--apg-bg-content` */
bgLight?: string;
bgDark?: string;
/** Raised surfaces (cards, elevated panels). */
/** Raised surfaces (cards, elevated panels). → `--apg-bg-elevated` */
elevatedLight?: string;
elevatedDark?: string;
/** Sidebar glass backdrop. */
/** Sidebar glass backdrop. → `--apg-sidebar-bg` */
sidebarBgLight?: string;
sidebarBgDark?: string;
/** Primary text color. */
/** Primary text color. → `--apg-text` */
textLight?: string;
textDark?: string;
/** Sidebar corner radius in px. */
/** Sidebar corner radius in px. → `--apg-sidebar-radius` */
sidebarRadius?: number;
/** Accent color (overrides `accentColor`). */
/** Accent color (overrides `accentColor`). → `--apg-accent` */
accent?: string;
// ---- extended token map (all optional; defaults keep today's look) ----
/** Accent used behind white text (buttons, menu hover) — needs AA contrast. → `--apg-accent-strong` */
accentStrongLight?: string;
accentStrongDark?: string;
/** Foreground color placed on top of the accent. → `--apg-accent-contrast` */
accentContrast?: string;
/** Destructive-action color. → `--apg-danger` */
dangerLight?: string;
dangerDark?: string;
/** Card surface (collections / pinned cards). → `--apg-card` */
cardLight?: string;
cardDark?: string;
/** Card surface on hover. → `--apg-card-hover` */
cardHoverLight?: string;
cardHoverDark?: string;
/** Top toolbar glass backdrop. → `--apg-toolbar-bg` */
toolbarBgLight?: string;
toolbarBgDark?: string;
/** Context-menu / popover glass fill. → `--apg-menu-bg` */
menuBgLight?: string;
menuBgDark?: string;
/** Hairline separators. → `--apg-separator` */
separatorLight?: string;
separatorDark?: string;
/** Stronger separators (input borders, scrollbars). → `--apg-separator-strong` */
separatorStrongLight?: string;
separatorStrongDark?: string;
/** Hover wash on rows / icon buttons. → `--apg-hover` */
hoverLight?: string;
hoverDark?: string;
/** Pressed/active wash. → `--apg-active` */
activeLight?: string;
activeDark?: string;
/** Selected sidebar row background. → `--apg-sidebar-selected` */
sidebarSelectedLight?: string;
sidebarSelectedDark?: string;
/** Secondary text (labels, captions). → `--apg-text-secondary` */
textSecondaryLight?: string;
textSecondaryDark?: string;
/** Tertiary text (hints, timestamps). → `--apg-text-tertiary` */
textTertiaryLight?: string;
textTertiaryDark?: string;
/** Border on glass surfaces (menus, sidebar, info panel). → `--apg-glass-border` */
glassBorderLight?: string;
glassBorderDark?: string;
/** Font stack for the whole gallery. → `--apg-font` */
fontFamily?: string;
/** Menu / popover corner radius in px. → `--apg-radius-menu` */
radiusMenu?: number;
/** Small elevation shadow. → `--apg-shadow-sm` */
shadowSm?: string;
/** Medium elevation shadow (menus, action bar). → `--apg-shadow-md` */
shadowMdLight?: string;
shadowMdDark?: string;
/** Large elevation shadow (modals). → `--apg-shadow-lg` */
shadowLgLight?: string;
shadowLgDark?: string;
/** Favourite heart on a grid tile. → `--apg-tile-fav` */
tileFav?: string;
/** Full-screen lightbox backdrop. → `--apg-overlay-bg` */
overlayBg?: string;
/** Photo/video editor chrome background. → `--apg-editor-bg` */
editorBg?: string;
/** Active pill inside a segmented control. → `--apg-segmented-active` */
segmentedActive?: string;
/** Sidebar width in px. → `--apg-sidebar-w` */
sidebarWidth?: number;
/** Toolbar height in px. → `--apg-toolbar-h` */
toolbarHeight?: number;
}
/**
* A server-backed, per-user lock for the Recently Deleted view.
*
* When a host supplies one the gallery delegates every lock operation to it and
* NEVER touches its device-local `localStorage` hash so the lock follows the
* user across devices and is enforced by the host's backend.
*/
export interface LockProvider {
/** Whether this user currently has a lock password configured. */
status(): Promise<{ hasPassword: boolean }>;
/** Set (or, with `null`, clear) the password. */
set(password: string | null): Promise<void>;
/** Check a password. Resolve `false` for a wrong password; reject on failure. */
verify(password: string): Promise<boolean>;
}
/** Which pieces of the gallery's own window chrome are rendered. */
export interface GalleryChrome {
/** macOS-style traffic-light title bar. */
titlebar: boolean;
/** Left navigation sidebar. */
sidebar: boolean;
/** Top toolbar (zoom / filter / search / more). */
toolbar: boolean;
/** Light/Dark/Semi-Dark items in the "More" menu. Turn off when the host owns the theme. */
themeSwitcher: boolean;
}
/** Default chrome: everything except the macOS titlebar (which stays opt-in). */
export const DEFAULT_CHROME: GalleryChrome = {
titlebar: false,
sidebar: true,
toolbar: true,
themeSwitcher: true,
};
export interface GalleryConfig {
features: GalleryFeatures;
accentColor: string;
@@ -88,6 +196,28 @@ export interface GalleryConfig {
title: string;
/** Optional theme-token overrides mapped to CSS variables at runtime. */
themeTokens?: ThemeTokens;
/** The host app's signed-in user. Enables identity-stamped comments. */
currentUser?: GalleryUser;
/** Base URL used to build share links (default: `${location.origin}/gallery`). */
shareBaseUrl?: string;
/** Which pieces of the gallery's own chrome to render. */
chrome: GalleryChrome;
/**
* Sidebar rows + Collections sections to hide (by ViewId), e.g.
* `['screenshots', 'sys:documents']`. Absent/`[]` = nothing hidden.
*/
hiddenViews?: ViewId[];
/** Whether global keyboard shortcuts are bound to `window`. */
keyboardShortcuts: boolean;
/** Embedded mode: lay out inside a host container instead of a full viewport. */
embedded: boolean;
/**
* Server-backed per-user lock for Recently Deleted. When present it REPLACES
* the device-local localStorage hash entirely.
*/
lockProvider?: LockProvider;
/** Start the gallery maximised over the host's chrome (default false). */
defaultFullscreen?: boolean;
}
export interface GalleryState {
@@ -149,15 +279,39 @@ export interface GalleryState {
/** Whether the custom camera is open. */
cameraOpen: boolean;
/** Client-side lock for the Recently Deleted view (hash persisted in localStorage). */
/** Whether the gallery is maximised over the host page (adds `apg--fullscreen`). */
fullscreen: boolean;
/**
* Client-side lock for the Recently Deleted view (hash persisted in localStorage).
* IGNORED when `config.lockProvider` is set read `lockConfigured` instead.
*/
lock: { hash: string | null };
/**
* Whether a lock password exists. Single source of truth for the UI: mirrors
* `lock.hash !== null` on the localStorage path and `lockProvider.status()`
* when a provider is configured.
*/
lockConfigured: boolean;
/** Whether the lock has been opened this session (in-memory, never persisted). */
lockUnlocked: boolean;
/**
* Last lock failure. `'wrong-password'` = verify() answered "no";
* `'unavailable'` = the provider threw / the network failed (a very different
* message for the user retry, don't re-type).
*/
lockError: 'wrong-password' | 'unavailable' | null;
/** Share records created on this device (persisted in localStorage). */
shares: ShareRecord[];
// ---- actions ----
init: (adapter: StorageAdapter, ai: AIProvider | null) => Promise<void>;
/**
* Replace the live config. `<PhotoGallery>` calls this whenever its props
* recompute, so a host changing theme tokens / user / chrome takes effect
* without remounting the gallery.
*/
setConfig: (config: GalleryConfig) => void;
addMedia: (items: MediaItem[]) => void;
/** Import File objects: builds MediaItems, uploads blobs to the adapter if it supports it. */
importFiles: (files: FileList | File[], albumId?: AlbumId) => Promise<MediaId[]>;
@@ -219,9 +373,16 @@ export interface GalleryState {
// Recently Deleted lock
setLockPassword: (password: string) => Promise<void>;
removeLockPassword: () => void;
removeLockPassword: () => Promise<void>;
unlockLock: (password: string) => Promise<boolean>;
relock: () => void;
/** Re-read `lockProvider.status()` (no-op without a provider). */
refreshLockStatus: () => Promise<void>;
clearLockError: () => void;
// Fullscreen / maximise
setFullscreen: (next: boolean) => void;
toggleFullscreen: () => void;
// Sharing
createShare: (scope: ShareRecord['scope'], mediaIds: MediaId[], albumId?: AlbumId) => ShareRecord;
@@ -293,20 +454,153 @@ function readShares(): ShareRecord[] {
}
}
/** Index entities by id, holding the object REFERENCE (identity = "unchanged"). */
function snapshotById<T extends { id: string }>(items: readonly T[]): Map<string, object> {
const map = new Map<string, object>();
for (const item of items) map.set(item.id, item);
return map;
}
function sameRecord(a: Record<string, string>, b: Record<string, string>): boolean {
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
return ak.every((k) => a[k] === b[k]);
}
function sameList(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
export function createGalleryStore(options: CreateStoreOptions) {
// Non-reactive references kept out of state.
let adapter: StorageAdapter | null = null;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
// ---- Incremental-persistence bookkeeping (per store instance) ----
// Snapshots of the LAST SUCCESSFULLY PERSISTED entities, by id, holding the
// object reference so a `!==` check is a complete change detector (the store
// never mutates entities in place — every update produces a new object).
let lastMedia = new Map<string, object>();
let lastAlbums = new Map<string, object>();
let lastPeople = new Map<string, object>();
let lastAliases: Record<string, string> = {};
let lastDeletedLabels: string[] = [];
/** Guards against overlapping in-flight writes reordering on a slow backend. */
let persisting = false;
let persistDirty = false;
/** storageRefs resolved by `uploadBlob` before the media item existed. */
const pendingStorageRefs = new Map<MediaId, string>();
const store = createStore<GalleryState>((set, get) => {
/** Seed the snapshots from a just-loaded backend state so a fresh load
* does not re-upload the entire library on the first persist. */
const seedSnapshots = (
media: readonly MediaItem[],
albums: readonly Album[],
people: readonly Person[],
labelAliases: Record<string, string>,
deletedLabels: string[],
) => {
lastMedia = snapshotById(media);
lastAlbums = snapshotById(albums.filter((a) => !a.system));
lastPeople = snapshotById(people);
lastAliases = { ...labelAliases };
lastDeletedLabels = [...deletedLabels];
};
const runPersist = async (): Promise<void> => {
if (!adapter) return;
// A slow backend must not be able to reorder writes: queue instead.
if (persisting) {
persistDirty = true;
return;
}
persisting = true;
try {
const { media, albums, people, labelAliases, deletedLabels } = get();
// Only persist user-owned albums; smart/system albums are regenerated.
const userAlbums = albums.filter((a) => !a.system);
if (!adapter.applyChanges) {
await adapter.save({
media,
albums: userAlbums,
people,
labelAliases,
deletedLabels,
version: 1,
});
// Keep the snapshots current so an adapter that gains applyChanges
// later (or a diff-based retry) starts from the persisted truth.
seedSnapshots(media, userAlbums, people, labelAliases, deletedLabels);
return;
}
const changes: StateChanges = {};
const nextMedia = snapshotById(media);
const nextAlbums = snapshotById(userAlbums);
const nextPeople = snapshotById(people);
const upsertMedia = media.filter((m) => lastMedia.get(m.id) !== m);
const removeMedia = [...lastMedia.keys()].filter((id) => !nextMedia.has(id));
const upsertAlbums = userAlbums.filter((a) => lastAlbums.get(a.id) !== a);
const removeAlbums = [...lastAlbums.keys()].filter((id) => !nextAlbums.has(id));
const upsertPeople = people.filter((p) => lastPeople.get(p.id) !== p);
const removePeople = [...lastPeople.keys()].filter((id) => !nextPeople.has(id));
if (upsertMedia.length) changes.upsertMedia = upsertMedia;
if (removeMedia.length) changes.removeMedia = removeMedia;
if (upsertAlbums.length) changes.upsertAlbums = upsertAlbums;
if (removeAlbums.length) changes.removeAlbums = removeAlbums;
if (upsertPeople.length) changes.upsertPeople = upsertPeople;
if (removePeople.length) changes.removePeople = removePeople;
if (!sameRecord(lastAliases, labelAliases)) changes.labelAliases = labelAliases;
if (!sameList(lastDeletedLabels, deletedLabels)) changes.deletedLabels = deletedLabels;
if (Object.keys(changes).length === 0) return;
// Optimistically advance the snapshots, then roll back on rejection so
// the very next persist retries exactly the same diff.
const prev = {
media: lastMedia,
albums: lastAlbums,
people: lastPeople,
aliases: lastAliases,
deletedLabels: lastDeletedLabels,
};
lastMedia = nextMedia;
lastAlbums = nextAlbums;
lastPeople = nextPeople;
lastAliases = { ...labelAliases };
lastDeletedLabels = [...deletedLabels];
try {
await adapter.applyChanges(changes);
} catch (err) {
lastMedia = prev.media;
lastAlbums = prev.albums;
lastPeople = prev.people;
lastAliases = prev.aliases;
lastDeletedLabels = prev.deletedLabels;
throw err;
}
} catch {
/* Persistence is best-effort — the app keeps working in memory. */
} finally {
persisting = false;
if (persistDirty) {
persistDirty = false;
persist();
}
}
};
const persist = () => {
if (!adapter) return;
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
const { media, albums, people, labelAliases, deletedLabels } = get();
// Only persist user-owned albums; smart/system albums are regenerated.
const userAlbums = albums.filter((a) => !a.system);
void adapter!.save({ media, albums: userAlbums, people, labelAliases, deletedLabels, version: 1 });
persistTimer = null;
void runPersist();
}, 400);
};
@@ -348,8 +642,13 @@ export function createGalleryStore(options: CreateStoreOptions) {
aiStatus: { running: false, done: 0, total: 0 },
infoOpen: false,
cameraOpen: false,
lock: { hash: lsGet(LOCK_KEY) },
fullscreen: options.config.defaultFullscreen ?? false,
// With a provider the localStorage hash is never read or written; the real
// answer arrives from `refreshLockStatus()` during init().
lock: { hash: options.config.lockProvider ? null : lsGet(LOCK_KEY) },
lockConfigured: options.config.lockProvider ? false : lsGet(LOCK_KEY) !== null,
lockUnlocked: false,
lockError: null,
shares: readShares(),
async init(a, ai) {
@@ -363,6 +662,15 @@ export function createGalleryStore(options: CreateStoreOptions) {
// demo `initialMedia` so a fresh install isn't blank.
const seed = get().media;
const seedEmptyBackend = loaded.media.length === 0 && seed.length > 0;
// Seed the diff snapshots from what the backend just returned, so the
// first persist only sends what actually changed afterwards.
seedSnapshots(
loaded.media,
loaded.albums,
loaded.people ?? [],
loaded.labelAliases ?? {},
loaded.deletedLabels ?? [],
);
set({
media: seedEmptyBackend ? seed : loaded.media,
albums: [...defaultSystemAlbums(ts), ...loaded.albums.filter((al) => !al.system)],
@@ -386,10 +694,35 @@ export function createGalleryStore(options: CreateStoreOptions) {
// Build object smart albums for an already-analyzed library (works even when
// AI is disabled — labels persisted from a prior session still group).
get().syncObjectAlbums();
// A server-backed lock owns the truth about whether a password exists.
await get().refreshLockStatus();
},
setConfig(config) {
// A host that only NOW supplies a lock provider (async auth, late mount)
// must get its real lock status. Keyed on presence, not identity, so an
// inline `lockProvider={{…}}` literal doesn't refetch on every render.
const had = Boolean(get().config.lockProvider);
const has = Boolean(config.lockProvider);
set({ config });
if (has && !had) void get().refreshLockStatus();
else if (!has && had) {
// Provider removed → fall back to the device-local hash.
const hash = lsGet(LOCK_KEY);
set({ lock: { hash }, lockConfigured: hash !== null, lockUnlocked: false });
}
},
addMedia(items) {
if (!items.length) return;
// Apply any storageRef resolved by uploadBlob() before the item existed.
const withRefs = items.map((item) => {
const ref = pendingStorageRefs.get(item.id);
if (!ref) return item;
pendingStorageRefs.delete(item.id);
return item.storageRef ? item : { ...item, storageRef: ref };
});
items = withRefs;
set((s) => ({ media: [...items, ...s.media] }));
// Auto-file captures/imports into source albums (created on first use).
const categories: Array<[string, string]> = [
@@ -409,14 +742,35 @@ export function createGalleryStore(options: CreateStoreOptions) {
async importFiles(files, albumId) {
const arr = Array.from(files as ArrayLike<File>);
const items: MediaItem[] = [];
// Stamp the importing user (display identity) when the host supplies one.
const currentUser = get().config.currentUser;
for (const f of arr) {
// mediaFromFile validates the type (image/video allow-list) and returns
// null for anything else — the backend validation for uploads.
const item = await mediaFromFile(f);
if (!item) continue;
// Record who imported it — but never overwrite an existing value on re-import.
if (currentUser && !item.uploadedBy) item.uploadedBy = currentUser;
const objectUrl = item.src; // blob: URL from mediaFromFile (dies on reload)
// Preferred path: a durable byte store that hands back BOTH a renderable
// URL and a permanent ref we can re-sign after a reload.
if (adapter?.putMedia) {
try {
const stored = await adapter.putMedia(item.id, f, {
name: item.name,
mime: item.mime,
});
if (stored?.url) {
item.src = stored.url;
item.storageRef = stored.ref;
item.thumbnail = undefined;
}
} catch {
/* fall through to putBlob / the data-URL fallback below */
}
}
// Upload to the backend (if the adapter supports blobs) for a durable URL.
if (adapter?.putBlob) {
if (!item.storageRef && adapter?.putBlob) {
try {
const url = await adapter.putBlob(item.id, f);
if (url) {
@@ -455,6 +809,30 @@ export function createGalleryStore(options: CreateStoreOptions) {
},
async uploadBlob(id, blob) {
if (adapter?.putMedia) {
try {
const existing = get().media.find((m) => m.id === id);
const stored = await adapter.putMedia(id, blob, {
name: existing?.name ?? id,
mime: existing?.mime ?? blob.type ?? 'application/octet-stream',
});
if (stored?.url) {
// Record the durable ref on the item. When the item doesn't exist
// yet (camera capture) it is stashed and applied by addMedia().
if (existing) {
set((s) => ({
media: s.media.map((m) => (m.id === id ? { ...m, storageRef: stored.ref } : m)),
}));
persist();
} else {
pendingStorageRefs.set(id, stored.ref);
}
return stored.url;
}
} catch {
/* fall through to putBlob below */
}
}
if (adapter?.putBlob) {
try {
return await adapter.putBlob(id, blob);
@@ -542,6 +920,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
addVersion(id, patch, changes) {
const now = Date.now();
const user = get().config.currentUser;
set((s) => ({
media: s.media.map((m) => {
if (m.id !== id) return m;
@@ -573,6 +952,10 @@ export function createGalleryStore(options: CreateStoreOptions) {
height: patch.height ?? m.height,
edits: patch.edits,
changes: changes.length ? changes : ['Edited'],
// Who made this edit (display identity), when the host supplies a user.
...(user
? { authorId: user.id, author: user.name, authorAvatar: user.avatarUrl }
: {}),
};
return { ...m, ...patch, versions: [...base, newVersion], editedAt: now };
}),
@@ -582,6 +965,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
restoreVersion(id, versionId) {
const now = Date.now();
const user = get().config.currentUser;
set((s) => ({
media: s.media.map((m) => {
if (m.id !== id || !m.versions) return m;
@@ -598,6 +982,10 @@ export function createGalleryStore(options: CreateStoreOptions) {
height: target.height,
edits: target.edits,
changes: [`Restored version ${target.version}`],
// Who performed the restore (display identity), when a user is set.
...(user
? { authorId: user.id, author: user.name, authorAvatar: user.avatarUrl }
: {}),
};
return {
...m,
@@ -617,7 +1005,19 @@ export function createGalleryStore(options: CreateStoreOptions) {
addComment(id, text, author) {
const trimmed = text.trim();
if (!trimmed) return;
const comment: MediaComment = { id: nanoid(8), author, text: trimmed, createdAt: Date.now() };
// With a host-provided user, comments carry a real identity instead of a
// free-text name (the backend is still the authority on `authorId`).
const user = get().config.currentUser;
const comment: MediaComment = user
? {
id: nanoid(8),
text: trimmed,
createdAt: Date.now(),
authorId: user.id,
author: author ?? user.name,
authorAvatar: user.avatarUrl,
}
: { id: nanoid(8), author, text: trimmed, createdAt: Date.now() };
set((s) => ({
media: s.media.map((m) =>
m.id === id ? { ...m, comments: [...(m.comments ?? []), comment] } : m,
@@ -626,6 +1026,16 @@ export function createGalleryStore(options: CreateStoreOptions) {
persist();
},
deleteComment(id, commentId) {
// Client-side mirror of the server rule: with a configured user you may
// only delete your own comments (legacy comments with no authorId stay
// deletable so pre-identity libraries keep working).
const user = get().config.currentUser;
if (user) {
const target = get()
.media.find((m) => m.id === id)
?.comments?.find((c) => c.id === commentId);
if (target && target.authorId !== undefined && target.authorId !== user.id) return;
}
set((s) => ({
media: s.media.map((m) =>
m.id === id ? { ...m, comments: (m.comments ?? []).filter((c) => c.id !== commentId) } : m,
@@ -984,22 +1394,81 @@ export function createGalleryStore(options: CreateStoreOptions) {
},
async setLockPassword(password) {
const provider = get().config.lockProvider;
if (provider) {
try {
await provider.set(password);
} catch {
set({ lockError: 'unavailable' });
return;
}
set({ lockConfigured: true, lockUnlocked: true, lockError: null });
return;
}
const hash = await hashPassword(password);
if (!hash) return;
lsSet(LOCK_KEY, hash);
set({ lock: { hash }, lockUnlocked: true });
set({ lock: { hash }, lockConfigured: true, lockUnlocked: true, lockError: null });
},
removeLockPassword() {
async removeLockPassword() {
const provider = get().config.lockProvider;
if (provider) {
try {
await provider.set(null);
} catch {
set({ lockError: 'unavailable' });
return;
}
set({ lockConfigured: false, lockUnlocked: false, lockError: null });
return;
}
lsSet(LOCK_KEY, null);
set({ lock: { hash: null }, lockUnlocked: false });
set({ lock: { hash: null }, lockConfigured: false, lockUnlocked: false, lockError: null });
},
async unlockLock(password) {
const provider = get().config.lockProvider;
if (provider) {
let ok: boolean;
try {
ok = await provider.verify(password);
} catch {
// A rejected verify is NOT a wrong password — say so.
set({ lockError: 'unavailable' });
return false;
}
set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
return ok;
}
const ok = await verifyPassword(password, get().lock.hash);
if (ok) set({ lockUnlocked: true });
set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
return ok;
},
relock() {
set({ lockUnlocked: false });
set({ lockUnlocked: false, lockError: null });
},
async refreshLockStatus() {
const provider = get().config.lockProvider;
if (!provider) return;
try {
const { hasPassword } = await provider.status();
set((s) => ({
lockConfigured: hasPassword,
// Losing the password server-side must not leave a stale unlock.
lockUnlocked: hasPassword ? s.lockUnlocked : false,
}));
} catch {
set({ lockError: 'unavailable' });
}
},
clearLockError() {
set({ lockError: null });
},
setFullscreen(next) {
set({ fullscreen: next });
},
toggleFullscreen() {
set((s) => ({ fullscreen: !s.fullscreen }));
},
createShare(scope, mediaIds, albumId) {
@@ -1013,6 +1482,8 @@ export function createGalleryStore(options: CreateStoreOptions) {
: `${mediaIds.length} Photos`;
const origin =
typeof location !== 'undefined' && location.origin ? location.origin : 'https://photos.app';
// The host owns where a share link points (its own route, not ours).
const base = get().config.shareBaseUrl ?? `${origin}/gallery`;
const share: ShareRecord = {
id: nanoid(12),
token,
@@ -1020,7 +1491,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
mediaIds,
albumId,
title,
url: `${origin}/gallery?shared=${token}`,
url: `${base}?shared=${token}`,
createdAt: Date.now(),
};
const next = [share, ...get().shares];
+658 -29
View File
@@ -292,29 +292,62 @@
background: var(--apg-bg-content);
}
/* ---- Toolbar ---- */
/* ---- Toolbar (floating rounded glass bar matches the sidebar) ----
The toolbar stays IN FLOW as a flex item of `.apg-main` and floats via margins
on all sides. Because it remains in flow, `.apg-viewport` naturally begins below
it the grid is never hidden behind the bar and scrolling stays inside the
viewport (no manual top-padding / overlap bookkeeping needed). `position:
relative` scopes the absolutely-centred control to the toolbar itself. */
.apg-toolbar {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: var(--apg-toolbar-h);
padding: 0 14px;
padding: 0 12px;
margin: 8px;
background: var(--apg-toolbar-bg);
backdrop-filter: saturate(180%) blur(24px);
-webkit-backdrop-filter: saturate(180%) blur(24px);
border-bottom: var(--apg-hairline) solid var(--apg-glass-border);
border: var(--apg-hairline) solid var(--apg-glass-border);
border-radius: var(--apg-radius-lg);
box-shadow: var(--apg-shadow-md);
flex-shrink: 0;
z-index: 20;
}
.apg-toolbar__spacer { flex: 1; }
.apg-toolbar__spacer { flex: 1 1 0; min-width: 0; }
/* In normal flow between two spacers: centred when there's room, squeezed (never
overlapping the right-hand zoom/filter group) when the toolbar is narrow. The old
`position:absolute; left:50%` overlapped the right group on any narrow toolbar
and its 720px media query keyed on VIEWPORT width, so it never fired when the
toolbar was narrowed by an embedding host's sidebars. */
.apg-toolbar__center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
}
@media (max-width: 720px) {
.apg-toolbar__center { position: static; transform: none; margin: 0 auto; }
/* Compact library-scale dropdown (replaces the wide 3-item segmented). */
.apg-scalemenu {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 8px 5px 12px;
border: none;
border-radius: 8px;
background: var(--apg-bg-elevated);
color: var(--apg-text);
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: default;
white-space: nowrap;
transition: background 0.12s ease;
}
.apg-scalemenu:hover { background: var(--apg-hover); }
.apg-scalemenu svg { color: var(--apg-text-secondary); }
.apg-scalemenu__label { line-height: 1; }
.apg-iconbtn {
width: 30px;
@@ -359,12 +392,12 @@
transition: background 0.15s ease;
}
.apg-segmented__item--active {
background: var(--apg-bg);
background: var(--apg-segmented-active, var(--apg-bg));
box-shadow: var(--apg-shadow-sm);
font-weight: 600;
}
.apg[data-theme='dark'] .apg-segmented__item--active {
background: #636366;
background: var(--apg-segmented-active, #636366);
}
/* Search */
@@ -395,9 +428,18 @@
.apg-search:focus-within {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--apg-accent) 32%, transparent);
}
/* ONE ring, not two: the global `.apg :focus-visible` outline would otherwise
draw a second line INSIDE the wrapper's ring. The wrapper ring is the
focus indicator for this control. */
.apg-search input:focus-visible,
.apg-search input:focus {
outline: none;
}
.apg-search__recents {
position: absolute;
top: calc(100% + 6px);
/* Clears the 3px focus ring so the popover's own hairline never reads as a
second ring stacked under the field. */
top: calc(100% + 9px);
right: 0;
min-width: 220px;
z-index: 60;
@@ -515,7 +557,7 @@
position: absolute;
bottom: 6px;
right: 7px;
color: #ff3b30;
color: var(--apg-tile-fav, #ff3b30);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
}
/* ---- Custom video player (macOS-style) ---- */
@@ -864,6 +906,20 @@
/* ---- Map thumbnail pins (macOS-style) ---- */
.apg-pin-wrap { background: none !important; border: none !important; }
/* Pins + their hover tooltip are pointer targets, never text: suppress selection so
dragging across the map never leaves a grey selection rectangle over the pin/preview. */
.apg-pin,
.apg-pin__tip {
-webkit-user-select: none;
user-select: none;
}
.apg-pin__img,
.apg-pin__tip-img,
.apg-pin__strip-cell img {
-webkit-user-drag: none;
user-select: none;
pointer-events: none;
}
.apg-pin {
position: relative;
display: block;
@@ -897,9 +953,12 @@
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
transform: translateX(-50%) scale(0.9);
/* --tip-dx is set per-hover by MapView so the tooltip slides back inside the map
when a pin sits near the left/right edge (instead of overflowing + being clipped). */
transform: translateX(calc(-50% + var(--tip-dx, 0px))) scale(0.9);
transform-origin: bottom center;
width: 168px;
max-width: 70vw;
padding: 5px;
border-radius: 12px;
background: var(--apg-menu-bg, #fff);
@@ -913,7 +972,24 @@
}
.apg-pin:hover .apg-pin__tip {
opacity: 1;
transform: translateX(-50%) scale(1);
transform: translateX(calc(-50% + var(--tip-dx, 0px))) scale(1);
}
/* Pin near the TOP of the map: flip the tooltip below so it isn't clipped off-screen. */
.apg-pin__tip--below {
bottom: auto;
top: calc(100% + 10px);
transform-origin: top center;
}
.apg-pin__tip--below::after { top: auto; bottom: 100%; }
/* Invisible bridge across the 10px gap so the pointer can travel from the pin
into the tooltip (to click a thumbnail) without dropping :hover. */
.apg-pin__tip::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 100%;
height: 12px;
}
.apg-pin__tip-img {
width: 100%;
@@ -950,19 +1026,71 @@
position: absolute;
left: 3px;
bottom: 3px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: rgba(0, 0, 0, 0.6);
min-width: 19px;
height: 19px;
padding: 0 5px;
border-radius: 10px;
/* Opaque enough to stay legible over a bright photo, with a hairline ring so
the digits never blend into a dark one. */
background: rgba(0, 0, 0, 0.78);
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.9);
color: #fff;
font-size: 11px;
font-size: 11.5px;
font-weight: 700;
line-height: 18px;
font-variant-numeric: tabular-nums;
line-height: 19px;
text-align: center;
pointer-events: none;
}
/* Hover mini-slider: a strip of the cluster's thumbnails under the preview. */
.apg-pin__strip {
display: none;
gap: 3px;
align-items: center;
margin-top: 5px;
}
.apg-pin__strip:not(:empty) { display: flex; }
.apg-pin__strip-cell {
flex: 1 1 0;
min-width: 0;
padding: 0;
border: none;
border-radius: 5px;
background: none;
overflow: hidden;
line-height: 0;
cursor: pointer;
opacity: 0.62;
transition: opacity 0.12s ease, box-shadow 0.12s ease;
}
.apg-pin__strip-cell img {
width: 100%;
height: 28px;
object-fit: cover;
display: block;
border-radius: 5px;
}
.apg-pin__strip-cell:hover { opacity: 1; }
.apg-pin__strip-cell--on {
opacity: 1;
box-shadow: 0 0 0 2px var(--apg-accent);
}
.apg-pin__strip-more {
flex: 0 0 auto;
padding: 0 5px;
height: 28px;
line-height: 28px;
border-radius: 5px;
background: var(--apg-hover, rgba(0, 0, 0, 0.08));
color: var(--apg-text-secondary);
font-size: 11px;
font-weight: 700;
}
/* The tooltip is only interactive while hovered (it is pointer-events:none
otherwise), so the strip's buttons stay clickable without stealing hits. */
.apg-pin:hover .apg-pin__tip { pointer-events: auto; }
/* ---- Map location sheet (tap a pin → its photos, date-grouped) ---- */
.apg-map__sheet {
position: absolute;
@@ -1016,6 +1144,227 @@
}
.apg-map__sheet-body { flex: 1; overflow-y: auto; min-height: 0; }
/* ---- Map sheet filter bar (search + date range + object chips) ---- */
.apg-map__sheet-filters {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 16px;
background: var(--apg-bg-elevated);
border-bottom: 1px solid var(--apg-separator);
}
.apg-mapfilter__row { display: flex; align-items: center; gap: 8px; }
.apg-mapfilter__search {
position: relative;
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
padding: 5px 9px;
border-radius: var(--apg-radius);
background: var(--apg-bg);
border: 1px solid var(--apg-separator);
color: var(--apg-text-secondary);
transition: box-shadow 0.15s ease;
}
.apg-mapfilter__search:focus-within {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--apg-accent) 32%, transparent);
}
.apg-mapfilter__search input {
flex: 1;
min-width: 0;
border: none;
background: none;
color: var(--apg-text);
font-family: inherit;
font-size: 13px;
}
/* One ring only — the wrapper owns the focus indicator. */
.apg-mapfilter__search input:focus,
.apg-mapfilter__search input:focus-visible { outline: none; }
.apg-mapfilter__search input::placeholder { color: var(--apg-text-secondary); }
/* Hide the UA's own clear affordance; ours resets the dates too. */
.apg-mapfilter__search input::-webkit-search-cancel-button { display: none; }
.apg-mapfilter__clear {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--apg-separator-strong);
color: var(--apg-text);
cursor: pointer;
}
.apg-mapfilter__dates { flex-wrap: wrap; }
.apg-mapfilter__date {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--apg-text-secondary);
}
.apg-mapfilter__date input {
padding: 4px 7px;
border-radius: var(--apg-radius-sm);
border: 1px solid var(--apg-separator);
background: var(--apg-bg);
color: var(--apg-text);
font-family: inherit;
font-size: 12px;
/* Safari sizes date inputs intrinsically; keep them from blowing out the row. */
max-width: 148px;
min-width: 0;
}
.apg-mapfilter__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 66px;
overflow-y: auto;
}
.apg-mapfilter__chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--apg-separator);
background: var(--apg-bg);
color: var(--apg-text);
font-family: inherit;
font-size: 12px;
cursor: pointer;
text-transform: capitalize;
}
.apg-mapfilter__chip:hover { background: var(--apg-hover); }
.apg-mapfilter__chip--on {
background: var(--apg-accent);
border-color: transparent;
color: var(--apg-accent-contrast, #fff);
font-weight: 600;
}
.apg-mapfilter__chip-n {
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.72;
}
.apg-mapfilter__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 34px 20px;
color: var(--apg-text-secondary);
text-align: center;
}
.apg-mapfilter__empty-title { font-size: 15px; font-weight: 600; color: var(--apg-text); }
.apg-mapfilter__empty-sub { font-size: 12.5px; }
/* ---- Map filter: single date-range control (button + presets/custom popover) ---- */
.apg-daterange { position: relative; }
.apg-daterange__button {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
border-radius: var(--apg-radius);
border: 1px solid var(--apg-separator);
background: var(--apg-bg);
color: var(--apg-text);
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
}
.apg-daterange__button:hover { background: var(--apg-hover); }
.apg-daterange__button svg:last-child { color: var(--apg-text-secondary); }
.apg-daterange__button--active {
border-color: var(--apg-accent);
color: var(--apg-accent);
}
.apg-daterange__button--active svg { color: var(--apg-accent); }
.apg-daterange__label { line-height: 1; white-space: nowrap; }
.apg-daterange__pop {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 60;
min-width: 232px;
padding: 8px;
background: var(--apg-menu-bg);
border: var(--apg-hairline) solid var(--apg-glass-border);
border-radius: var(--apg-radius-menu);
box-shadow: var(--apg-shadow-md);
backdrop-filter: blur(34px) saturate(180%);
-webkit-backdrop-filter: blur(34px) saturate(180%);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.apg-daterange__pop { background: var(--apg-bg-elevated); }
}
.apg-daterange__presets {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.apg-daterange__preset {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--apg-separator);
border-radius: var(--apg-radius-sm);
background: var(--apg-bg);
color: var(--apg-text);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
text-align: left;
}
.apg-daterange__preset:hover { background: var(--apg-hover); }
.apg-daterange__preset--on {
border-color: transparent;
background: var(--apg-accent);
color: var(--apg-accent-contrast, #fff);
font-weight: 600;
}
.apg-daterange__preset--on svg { color: var(--apg-accent-contrast, #fff); }
.apg-daterange__custom { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--apg-separator); }
.apg-daterange__custom-label {
font-size: 11px;
font-weight: 600;
color: var(--apg-text-secondary);
padding: 0 2px 6px;
}
.apg-daterange__fields { display: flex; gap: 8px; }
.apg-daterange__field {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 0;
font-size: 11px;
color: var(--apg-text-secondary);
}
.apg-daterange__field input {
padding: 5px 7px;
border-radius: var(--apg-radius-sm);
border: 1px solid var(--apg-separator);
background: var(--apg-bg);
color: var(--apg-text);
font-family: inherit;
font-size: 12px;
min-width: 0;
}
.apg-daterange__field input:focus { outline: none; border-color: var(--apg-accent); }
/* ---- Mosaic / Memories grid (Map → Grid tab) ---- */
.apg-mosaic-wrap { padding: 4px 0 28px; }
.apg-mosaic-section { padding: 6px 16px 10px; }
@@ -1094,7 +1443,7 @@
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.97);
background: var(--apg-overlay-bg, rgba(0, 0, 0, 0.97));
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
@@ -1254,13 +1603,30 @@
.apg-modal__list-item:hover { background: var(--apg-hover); }
/* ---- Editor ---- */
.apg-editor { position: fixed; inset: 0; z-index: 1050; background: #161617; display: flex; flex-direction: column; color: #fff; }
.apg-editor { position: fixed; inset: 0; z-index: 1050; background: var(--apg-editor-bg, #161617); display: flex; flex-direction: column; color: #fff; font-family: var(--apg-font); }
.apg-editor__bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.apg-editor__bar .apg-iconbtn { color: #fff; }
.apg-editor__bar .apg-iconbtn:hover { background: rgba(255,255,255,0.12); }
.apg-editor__body { flex: 1; display: flex; min-height: 0; }
.apg-editor__canvaswrap { flex: 1; display: grid; place-items: center; padding: 18px; min-width: 0; }
.apg-editor__canvaswrap { flex: 1; display: grid; place-items: center; gap: 12px; padding: 18px; min-width: 0; }
.apg-editor__canvaswrap canvas { max-width: 100%; max-height: 100%; border-radius: 4px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); }
.apg-editor__canvaswrap video { cursor: pointer; }
/* Custom video transport sibling of the (transformable) preview, so it never
rotates/flips with the frame. */
.apg-vedit__transport {
display: flex;
align-items: center;
gap: 10px;
width: min(560px, 100%);
padding: 6px 10px;
border-radius: 999px;
background: rgba(30, 30, 32, 0.55);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.apg-vedit__transport .apg-iconbtn { color: #fff; flex: none; }
.apg-vedit__scrub { flex: 1; min-width: 0; accent-color: var(--apg-accent); cursor: pointer; }
.apg-vedit__time { flex: none; font-size: 11.5px; color: rgba(255, 255, 255, 0.8); font-variant-numeric: tabular-nums; }
.apg-editor__panel { width: 300px; flex-shrink: 0; border-left: 1px solid rgba(255,255,255,0.1); padding: 16px; overflow-y: auto; }
.apg-editor__tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 14px; }
.apg-editor__tab { flex: 1 1 auto; padding: 6px 8px; border-radius: 7px; background: rgba(255,255,255,0.08); border: none; color: #fff; font-size: 12px; cursor: pointer; white-space: nowrap; }
@@ -1462,7 +1828,8 @@
/* ---- Info panel ---- */
.apg-info {
position: fixed;
top: 64px;
/* A host with its own header can push the panel below it via --apg-overlay-top. */
top: var(--apg-overlay-top, 64px);
right: 14px;
z-index: 1300;
width: 300px;
@@ -1496,6 +1863,14 @@
border-radius: var(--apg-radius);
background: var(--apg-bg);
}
/* Video preview: show the whole frame (never crop) over a dark backing, and let the
native control bar sit below without forcing a fixed square. */
.apg-info__thumb--video {
height: auto;
max-height: 220px;
object-fit: contain;
background: #000;
}
.apg-info__name { font-weight: 600; font-size: 14px; margin-top: 10px; word-break: break-all; }
.apg-info__sub { color: var(--apg-text-secondary); font-size: 12px; margin-bottom: 10px; }
.apg-info__row {
@@ -1608,9 +1983,174 @@
}
.apg-version__changes { list-style: disc; margin: 0 0 8px; padding-left: 16px; font-size: 12px; color: var(--apg-text); }
.apg-version__changes li { padding: 1px 0; }
/* Version author (who created this version) — avatar + name under the timestamp. */
.apg-version__author { display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; }
.apg-version__author-avatar {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
background: var(--apg-bg-elevated);
}
.apg-version__author-avatar--initial {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--apg-accent);
color: #fff;
font-size: 9px;
font-weight: 700;
}
.apg-version__author-name { font-size: 11px; color: var(--apg-text-secondary); }
.apg-comments { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.apg-comment { display: flex; gap: 8px; align-items: flex-start; }
/* ---- Info panel: "Uploaded by" identity block ---- */
.apg-info__uploader {
/* Breathing room above (from the caption/note) AND below, so the card is a
distinct block, not glued to the "Analyzing…" line or the detail rows. */
margin: 12px 0 14px;
padding: 8px 10px;
border-radius: var(--apg-radius);
background: var(--apg-bg-elevated);
border: 1px solid var(--apg-separator);
}
.apg-info__uploader-label {
display: block;
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--apg-text-tertiary);
margin-bottom: 6px;
}
.apg-info__uploader-row { display: flex; align-items: center; gap: 9px; }
.apg-info__uploader-avatar {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
background: var(--apg-bg);
}
.apg-info__uploader-avatar--initial {
display: flex;
align-items: center;
justify-content: center;
background: var(--apg-accent);
color: #fff;
font-size: 13px;
font-weight: 700;
}
.apg-info__uploader-meta { display: flex; flex-direction: column; min-width: 0; }
.apg-info__uploader-name { font-size: 13px; font-weight: 600; color: var(--apg-text); }
.apg-info__uploader-email {
font-size: 11px;
color: var(--apg-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Info panel: editable caption / note ---- */
.apg-editable {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
margin-top: 10px;
padding: 7px 9px;
text-align: left;
border: 1px solid transparent;
border-radius: var(--apg-radius);
background: var(--apg-bg-elevated);
color: var(--apg-text);
cursor: text;
font-family: inherit;
}
.apg-editable:hover { border-color: var(--apg-separator-strong); }
.apg-editable__label {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--apg-text-tertiary);
}
.apg-editable__label svg { margin-left: auto; opacity: 0.55; }
.apg-editable__edited {
text-transform: none;
letter-spacing: 0;
font-weight: 500;
font-size: 10px;
font-style: italic;
color: var(--apg-text-tertiary);
}
.apg-editable__value { font-size: 12.5px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
.apg-editable__value--empty { color: var(--apg-text-tertiary); font-style: italic; }
.apg-editable--editing { cursor: default; }
.apg-editable__input {
width: 100%;
border: 1px solid var(--apg-accent);
background: var(--apg-bg);
color: var(--apg-text);
border-radius: var(--apg-radius-sm);
padding: 6px 8px;
font-size: 12.5px;
font-family: inherit;
line-height: 1.4;
resize: vertical;
outline: none;
}
/* ---- Info panel: full reverse-geocoded address ---- */
.apg-address { margin-top: 6px; }
.apg-address__line {
display: flex;
align-items: flex-start;
gap: 6px;
width: 100%;
text-align: left;
padding: 7px 9px;
border-radius: var(--apg-radius);
border: 1px solid var(--apg-glass-border);
background: var(--apg-bg-elevated);
color: var(--apg-text);
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.apg-address__line svg { flex-shrink: 0; margin-top: 1px; color: var(--apg-accent); }
.apg-address__line:hover { border-color: var(--apg-accent); }
.apg-address__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin: 8px 0 0;
}
.apg-address__cell { display: flex; flex-direction: column; gap: 1px; }
.apg-address__cell dt {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--apg-text-tertiary);
}
.apg-address__cell dd { margin: 0; font-size: 12px; color: var(--apg-text); word-break: break-word; }
.apg-comments { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 14px; }
/* Each comment is a distinct card so consecutive comments (and the compose box
below) read as separate, not one run-on block. */
.apg-comment {
display: flex; gap: 8px; align-items: flex-start;
padding: 8px 9px;
border-radius: 10px;
background: var(--apg-bg-elevated);
border: 1px solid var(--apg-separator);
}
/* Separate the compose form from the comment list above it. */
.apg-comment-form { margin-top: 4px; padding-top: 12px; border-top: 1px solid var(--apg-separator); }
.apg-comment__avatar {
width: 26px;
height: 26px;
@@ -1624,6 +2164,22 @@
font-size: 12px;
font-weight: 700;
}
/* Real avatar image (host-provided identity) — same footprint as the initial. */
.apg-comment__avatar-img {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
display: block;
background: var(--apg-bg-elevated);
}
.apg-comment-form__identity {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0 2px;
}
.apg-comment__body { flex: 1; min-width: 0; }
.apg-comment__meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.apg-comment__author { font-size: 12.5px; font-weight: 600; }
@@ -1675,7 +2231,7 @@
.apg-info__analyzing {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 500; color: var(--apg-accent);
padding: 8px 10px; margin: 2px 0 4px;
padding: 8px 10px; margin: 6px 0 10px;
background: color-mix(in srgb, var(--apg-accent) 10%, transparent);
border-radius: 8px;
}
@@ -1852,3 +2408,76 @@
outline-offset: 2px;
border-radius: 4px;
}
/* =========================================================================
Embedded mode the gallery lives inside a host app's layout.
The full-screen overlays (lightbox, editor, camera, modals, context menu)
intentionally stay `position: fixed`: that is correct for a modal rendered
over a host application. Only the in-flow layout changes here.
========================================================================= */
.apg--embedded {
height: 100%;
min-height: 0;
}
.apg--embedded .apg__body {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
/* macOS uses `cursor: default` on controls; inside a web app that reads as
broken, so embedded mode restores the expected pointer affordance. */
.apg--embedded .apg-btn,
.apg--embedded .apg-iconbtn,
.apg--embedded .apg-tile,
.apg--embedded .apg-chip,
.apg--embedded .apg-menu__item,
.apg--embedded .apg-modal__list-item,
.apg--embedded .apg-sidebar__item,
.apg--embedded .apg-segmented__item,
.apg--embedded .apg-lightbox__nav,
.apg--embedded .apg-collections__action,
.apg--embedded .apg-pinned-card,
.apg--embedded .apg-editor__filter,
.apg--embedded .apg-camera__tool,
.apg--embedded .apg-scalemenu,
.apg--embedded .apg-daterange__button,
.apg--embedded .apg-daterange__preset,
.apg--embedded .apg-editable {
cursor: pointer;
}
.apg--embedded .apg-btn:disabled,
.apg--embedded .apg-iconbtn:disabled {
cursor: not-allowed;
}
/* =========================================================================
Full screen / maximise the gallery covers the host page.
Why the SDK's own overlays need no change: the lightbox / editor / camera /
modals are `position: fixed; inset: 0`, so they resolve against the VIEWPORT
(a plain `position: fixed` ancestor is not a containing block for them only
transform/filter/contain would be). Full screen makes `.apg` cover exactly the
viewport too, so the two coincide and the overlays land in the right place.
And because `z-index: 1400` opens a stacking context, their 1000-1300 z-indexes
now stack INSIDE it above the gallery's content, and (via 1400) above the
host's chrome. Nothing else needs raising. `overflow: hidden` on `.apg` does
not clip them, for the same containing-block reason.
========================================================================= */
.apg--fullscreen {
position: fixed;
inset: 0;
z-index: 1400;
width: 100%;
/* Fill the viewport regardless of what height the host container had.
`dvh` (where supported) keeps mobile browser chrome from cropping it. */
height: 100%;
height: 100dvh;
max-height: none;
border-radius: 0;
}
.apg--fullscreen .apg__body {
flex: 1 1 auto;
min-height: 0;
height: auto;
}
+51 -1
View File
@@ -28,8 +28,18 @@ export interface GeoLocation {
lng: number;
/** Reverse-geocoded human label, e.g. "Mumbai, India". */
place?: string;
// ---- full-address fields (filled by reverse geocoding, all optional) ----
road?: string;
neighbourhood?: string;
suburb?: string;
city?: string;
county?: string;
state?: string;
postcode?: string;
country?: string;
countryCode?: string;
/** Full one-line address from reverse geocoding (Nominatim display_name). */
formatted?: string;
}
/** Non-destructive edit adjustments (all normalized -1..1 unless noted). */
@@ -194,6 +204,11 @@ export interface MediaItem {
kind: MediaKind;
/** Full-resolution source (URL, blob URL, or data URL). */
src: string;
/**
* Durable backend reference for the bytes (adapter-owned). `src` may be a
* short-lived signed URL derived from this; `storageRef` is what survives a reload.
*/
storageRef?: string;
/** Optional smaller thumbnail; falls back to `src`. */
thumbnail?: string;
/** Optional poster frame for videos. */
@@ -230,6 +245,16 @@ export interface MediaItem {
location?: GeoLocation;
exif?: Record<string, string | number>;
/** Who imported/captured this item (display identity; be-crm still owns owner_principal_id). */
uploadedBy?: GalleryUser;
/**
* User-authored free-form note (multi-line). Editable from the Info panel and
* included in the search haystack.
* @security Rendered ONLY via React JSX / textContent never innerHTML.
*/
note?: string;
// AI-derived metadata (optional, filled lazily by providers).
caption?: string;
/**
@@ -280,14 +305,39 @@ export interface MediaVersion {
changes: string[];
/** Optional user label / note. */
note?: string;
// ---- who created this version (display identity; all optional) ----
authorId?: string;
author?: string;
authorAvatar?: string;
}
/** A comment left on a photo/video. */
export interface MediaComment {
id: string;
author?: string;
text: string;
createdAt: number;
/** Display name shown next to the comment. */
author?: string;
/**
* The host app's stable user id for the comment's author. Set/enforced by the
* backend, not the client the UI only uses it to decide whether the current
* user may delete this comment.
*/
authorId?: string;
/** Avatar image URL for the author, when the host app knows one. */
authorAvatar?: string;
}
/**
* The signed-in user of the HOST app. When provided the gallery stamps comments
* with a real identity instead of a free-text name, and shows delete affordances
* only on the current user's own comments.
*/
export interface GalleryUser {
id: string;
name: string;
email?: string;
avatarUrl?: string;
}
export type AlbumKind = 'user' | 'smart' | 'folder' | 'shared';