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:
@@ -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).
|
- [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).
|
- 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)
|
## Hard rules (do not violate)
|
||||||
- **Never `git commit` / `git push`** — the user does that. Never commit secrets.
|
- **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.
|
- API keys live only in `apps/web/.env.local` (gitignored), read **server-side** only.
|
||||||
|
|||||||
@@ -12,34 +12,50 @@ Status: ✅ done · 🚧 in progress · ⬜ planned · 🔑 needs key/creds. Cro
|
|||||||
- ✅ Info panel (full metadata + **mini-map**). Themes menu + `accentColor`/`borderRadius` props.
|
- ✅ 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).
|
- ✅ Gemini generative editor (Remove BG/Restore/Colorize/Replace Sky/prompt) — 🔑 needs billed key (free=429).
|
||||||
|
|
||||||
## Now / next (in user's priority order)
|
## Now / next (in user's priority order) — all ✅ done
|
||||||
1. 🚧 **Custom camera** (#1): getUserMedia preview; capture photo; record video; switch front/back; grid;
|
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.
|
**live annotation + measurement**; geolocation on capture (permission); auto object-detect after capture.
|
||||||
2. ⬜ **Supabase backend + storage** (#last, 🔑): Postgres data + Supabase Storage. **No localStorage reliance** —
|
2. ✅ **Supabase backend + storage** (🔑): Postgres data + Supabase Storage adapter (run `docs/supabase-setup.sql`
|
||||||
backend is the source of truth. Ask user for creds (URL, anon key, service-role key, bucket) with steps.
|
once). Backend is the source of truth on load. The CRM host supplies its own **incremental** data-door adapter
|
||||||
3. ⬜ Face clustering → People; OCR (tesseract.js) → document search; semantic "show me beach photos"
|
(`applyChanges` + `putMedia`); localStorage remains the zero-config dev default.
|
||||||
(transformers.js / Hugging Face CLIP) — all free, in-browser.
|
3. ✅ Face clustering → People; OCR (tesseract.js) → Documents + searchable text; semantic search
|
||||||
4. ⬜ Video editor + annotations; macOS-parity player polish.
|
(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)
|
## New requests (early sessions — now all done)
|
||||||
- ⬜ **Editor: straighten / tilt** (angle slider) — rotate by arbitrary degrees.
|
- ✅ **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: free-form AND fixed-ratio** — a selected ratio (e.g. 1:1) is kept while dragging; also free crop.
|
||||||
crop box keeps that aspect ratio; also free crop.
|
- ✅ **Import sync of existing metadata**: IPTC/XMP keywords + location read on import (exifr) alongside object detection.
|
||||||
- ⬜ **Import sync of existing metadata**: if an imported/mobile image already has tags (IPTC/XMP keywords) +
|
- ✅ **Web-meaningful UI** (deep pass): "All Projects" → **"All Albums"**; removed "App Store"; sidebar tabs / corner
|
||||||
location + details, read and apply them (exifr supports XMP/IPTC). Keep object detection + object albums.
|
radius / touch-ups reviewed for a web gallery matching macOS.
|
||||||
- 🚧 **Web-meaningful UI** (deep pass): "All Projects" → **"All Albums"** (this is a web gallery, not iOS);
|
- ✅ **Search dropdown defaults**: Recently Viewed / Recently Edited / Recently Added when the search field is focused.
|
||||||
remove "Open App Store" (meaningless on web); review sidebar tabs, **corner radius**, touch-ups so everything
|
- ✅ **Info → map click opens full Map** centered on that photo's location.
|
||||||
is meaningful for a web gallery and matches macOS more closely.
|
- ✅ **Map parity w/ macOS**: click a tag → that tag's images grouped by date; thumbnail pins; location sheets.
|
||||||
- ⬜ **Search dropdown defaults**: show quick filters **Recently Viewed**, **Recently Edited** (+ Recently Shared)
|
- ✅ **Local-model fallback**: `@imgly/background-removal` (free, in-browser) wired for Remove Background — no key needed.
|
||||||
when the search field is focused.
|
- ✅ **Everything free**; build our own where no free option (analysis/OCR/faces/semantic/bg-remove all run in-browser).
|
||||||
- ⬜ **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.
|
|
||||||
- ✅ **Memory in project `.claude/` folder** (this folder) — done.
|
- ✅ **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
|
## Notes
|
||||||
- getUserMedia needs HTTPS or localhost (dev OK). Permissions-Policy already allows camera/mic/geolocation=self.
|
- getUserMedia needs HTTPS or localhost (dev OK). Permissions-Policy already allows camera/mic/geolocation=self.
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
## Run
|
## Run
|
||||||
`pnpm install && pnpm dev` → http://localhost:3000 (landing → Open Gallery → /gallery). `pnpm build` to ship.
|
`pnpm install && pnpm dev` → http://localhost:3000 (landing → Open Gallery → /gallery). `pnpm build` to ship.
|
||||||
|
|
||||||
## Current state (2026-06-29)
|
## Current state (2026-07-23)
|
||||||
Phases 1–3 complete & verified (see [requirements.md](requirements.md)). `pnpm build` passes; /gallery ~170 KB
|
All phases + both big user batches + versioning/comments + advanced video editor + RunPod AI wiring + **host-app (CRM)
|
||||||
(TF.js + exifr lazy). Zero console errors. Default persistence = localStorage adapter (until Supabase wired).
|
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
|
## Session log
|
||||||
- S1: monorepo + SDK + demo; full macOS Photos UI; albums/smart-albums/recycle/import/editor/lightbox; light+dark;
|
- S1: monorepo + SDK + demo; full macOS Photos UI; albums/smart-albums/recycle/import/editor/lightbox; light+dark;
|
||||||
@@ -344,7 +348,52 @@ Phases 1–3 complete & verified (see [requirements.md](requirements.md)). `pnpm
|
|||||||
REFUTED (correctly, no change): status-URL query-string, combined-body-cap rejecting masks, replace-sky client
|
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).
|
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
|
## 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`.
|
- 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`.
|
- 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).
|
- AI edit backend (S-deploy-ai): `apps/web/src/app/api/ai/edit/route.ts` (env AI_EDIT_PROVIDER).
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
# RUNPOD_BG_REMOVE_URL=https://api.runpod.ai/v2/<id>/runsync # #6 (optional; default is in-browser @imgly)
|
# 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)
|
# 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_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
|
# 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)
|
# 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)
|
# RUNPOD_AUDIO_DENOISE_URL (#12) RUNPOD_VIDEO_FPS_URL (#13) RUNPOD_VIDEO_UPSCALE_URL (#14)
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
A **reusable macOS Photos–style photo gallery SDK** (`@photo-gallery/sdk`) for React / Next.js, plus a
|
A **reusable macOS Photos–style 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
|
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
|
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
|
it needs. It is **not just the standalone demo** — the SDK now **embeds into a host app** (e.g. the
|
||||||
(done / in-progress / planned) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the design.
|
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
|
## 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
|
- Storage is a pluggable `StorageAdapter` (default: localStorage/IndexedDB). AI is a pluggable
|
||||||
`AIProvider` (object detection / faces / OCR / caption / embeddings / generativeEdit) — all optional.
|
`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, ... }}`).
|
- 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,
|
- 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.
|
filename sanitization, server-only API keys (never `NEXT_PUBLIC_`). Don't regress this.
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ what it needs. The full, living checklist is in [`docs/ROADMAP.md`](docs/ROADMAP
|
|||||||
### Platform
|
### Platform
|
||||||
| Area | Details |
|
| Area | Details |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Backend** | Pluggable `StorageAdapter` — zero‑config localStorage/IndexedDB default, or **Supabase** (Postgres + Storage) adapter included |
|
| **Backend** | Pluggable `StorageAdapter` — zero‑config localStorage/IndexedDB default, or **Supabase** (Postgres + Storage) adapter included; optional **incremental** `applyChanges` + durable `putMedia` for backend‑friendly partial writes |
|
||||||
| **Search** | Name / place / tag / object / **OCR text** / **semantic** — blended and relevance‑ranked |
|
| **Embedding** | Drop into a host app: `embedded` layout, `chrome` suppression, `hiddenViews`, host **`currentUser`** identity (identity‑stamped comments / uploads / versions), `shareBaseUrl`, server‑backed **`lockProvider`**, full‑screen mode — live config (no remount) |
|
||||||
| **Theming** | `accentColor` + `borderRadius` props; Light / Dark / Semi‑Dark / System |
|
| **Search** | Name / place / tag / object / **OCR text** / caption / note / **semantic** — blended and relevance‑ranked |
|
||||||
|
| **Theming** | `accentColor` + `borderRadius` props **and a full `ThemeTokens` map (50+ keys)** → CSS variables; Light / Dark / Semi‑Dark / System |
|
||||||
| **A11y / responsive** | Keyboard nav, focus traps, reduced‑motion, ARIA; works down to ~280 px |
|
| **A11y / responsive** | Keyboard nav, focus traps, reduced‑motion, ARIA; works down to ~280 px |
|
||||||
| **Security** | Per‑request **nonce CSP**, security headers, filename sanitisation, URL‑scheme allow‑list, server‑only API keys |
|
| **Security** | Per‑request **nonce CSP**, security headers, filename sanitisation, URL‑scheme allow‑list, server‑only API keys |
|
||||||
|
|
||||||
@@ -149,14 +150,29 @@ export default function Gallery() {
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `photos` | `MediaItem[] \| { src, … }[]` | `[]` | Initial library; loose inputs are normalised + auto‑classified |
|
| `photos` | `MediaItem[] \| { src, … }[]` | `[]` | Initial library; loose inputs are normalised + auto‑classified |
|
||||||
| `albums` | `Album[]` | `[]` | Initial user albums |
|
| `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 |
|
| `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 |
|
| `accentColor` | `string` | `#0a84ff` | Accent colour |
|
||||||
| `borderRadius` | `number` | `10` | Base corner radius (px) for all rounded UI |
|
| `borderRadius` | `number` | `10` | Base corner radius (px) for all rounded UI |
|
||||||
| `themeTokens` | `ThemeTokens` | – | Per‑theme background / gradient / opacity / sidebar colour + radius overrides |
|
| `themeTokens` | `ThemeTokens` | – | Full per‑theme 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 |
|
| `features` | `Partial<GalleryFeatures>` | all on | Toggle editor / camera / ai / map / import / export / sharing |
|
||||||
| `showWindowChrome` | `boolean` | `false` | Render the macOS traffic‑light title bar |
|
| `showWindowChrome` | `boolean` | `false` | Render the macOS traffic‑light 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 signed‑in 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` | – | Server‑backed, per‑user Recently‑Deleted lock (replaces the device‑local hash) |
|
||||||
|
|
||||||
|
The live config is applied on every render, so changing `theme`, `themeTokens`, `currentUser` or `chrome` re‑applies without remounting the gallery.
|
||||||
|
|
||||||
### 🎨 Customization (props **or** env)
|
### 🎨 Customization (props **or** env)
|
||||||
|
|
||||||
@@ -188,6 +204,14 @@ const myApiAdapter: StorageAdapter = {
|
|||||||
<PhotoGallery adapter={myApiAdapter} />;
|
<PhotoGallery adapter={myApiAdapter} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Two optional members make an adapter backend‑friendly (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
|
### Custom AI provider
|
||||||
|
|
||||||
Every method is optional and the UI degrades gracefully. Implement object detection client‑side
|
Every method is optional and the UI degrades gracefully. Implement object detection client‑side
|
||||||
@@ -250,7 +274,8 @@ Still planned / not yet implemented:
|
|||||||
|
|
||||||
- **Auth & multi‑user sharing** (real recipients, permissions). Sharing today creates device‑local links.
|
- **Auth & multi‑user sharing** (real recipients, permissions). Sharing today creates device‑local links.
|
||||||
- **Captions** (transformers.js BLIP) and a first‑class Hugging Face Inference provider option.
|
- **Captions** (transformers.js BLIP) and a first‑class Hugging Face Inference provider option.
|
||||||
- **Map** heatmaps / trips / reverse‑geocode place names; true grid **virtualization** for millions of items.
|
- **Map** heatmaps / trips; true grid **virtualization** for millions of items. (Full‑address **reverse‑geocoding**
|
||||||
|
— road → city → country via OpenStreetMap Nominatim, shown in the Info panel — is now live.)
|
||||||
- **Server‑side** 30‑day purge (pg_cron), Storage blob deletion on permanent‑delete.
|
- **Server‑side** 30‑day purge (pg_cron), Storage blob deletion on permanent‑delete.
|
||||||
- Storybook + API docs, Docker/K8s, automated CI.
|
- Storybook + API docs, Docker/K8s, automated CI.
|
||||||
|
|
||||||
|
|||||||
+83
-2
@@ -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.
|
`normalizeMediaItem` (URL-scheme allow-list, string coercion) on both the `photos` prop and adapter load.
|
||||||
|
|
||||||
## Adapters (storage) — all optional
|
## 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).
|
- 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.
|
- 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 1000–1300 layers live inside.
|
||||||
|
Escape un-maximises, but only after the lightbox/editor/camera/modal/context-menu check and after
|
||||||
|
clearing an object focus or selection.
|
||||||
|
`lockProvider` makes the Recently Deleted lock server-backed and per-user: `status` / `set(password |
|
||||||
|
null)` / `verify`. When present the store never reads or writes the `apg:lock-hash` localStorage entry
|
||||||
|
— `lockConfigured` (seeded from `status()` in `init()`) is the UI's source of truth, and `lockError`
|
||||||
|
separates `'wrong-password'` (verify resolved false) from `'unavailable'` (verify rejected). Without a
|
||||||
|
provider the localStorage path is byte-for-byte unchanged.
|
||||||
|
Full prop/token tables: [`packages/photo-sdk/README.md`](../packages/photo-sdk/README.md#embedding-in-a-host-app).
|
||||||
|
|
||||||
|
## Map view
|
||||||
|
Leaflet is dynamically imported (never at module scope, so SSR is safe) and driven through refs.
|
||||||
|
`clusterByLocation` groups located media by coordinate precision that rises with zoom, so pins split
|
||||||
|
apart as you go in. Clicking a pin `flyTo`s it — `max(currentZoom, 14)` for a lone photo, `max(…, 12)`
|
||||||
|
for a cluster, never zooming out — then pans up by half the sheet height so the pin clears the sheet
|
||||||
|
that is about to cover the bottom half. Multi-photo pins carry a total-count badge and a hover
|
||||||
|
mini-slider: a strip of up to 5 thumbnails plus a `+N` chip that auto-advances the preview every 900 ms
|
||||||
|
while hovered. The single-photo hover tooltip (`.apg-pin__tip`) is `user-select:none` and edge-clamped —
|
||||||
|
MapView measures it and sets `--tip-dx` to slide it back inside the map, adding `.apg-pin__tip--below` to
|
||||||
|
flip it under the pin near the top edge — so it can't overflow or get clipped. The strip is built with plain DOM (matching the marker code, and keeping place names /
|
||||||
|
filenames on `textContent`, never `innerHTML`); every interval is registered in a set that marker
|
||||||
|
teardown, mode changes and unmount all drain, because a leaked one would mutate detached DOM forever.
|
||||||
|
The location sheet has its own filter bar: a search field reusing `searchMedia` (so "objects" mean the
|
||||||
|
same thing as in the global search), a single **date-range** control (`.apg-daterange` — a button that
|
||||||
|
opens a popover of presets `All dates / Last 7 days / Last 30 days / This year` plus a custom From/To
|
||||||
|
pair, all driving one `takenAt` from/to state), and a row of object-label chips built from the cluster
|
||||||
|
— all AND-combined, with a live "N of M photos" count, one clear button that resets everything, and an
|
||||||
|
empty state when nothing survives.
|
||||||
|
|
||||||
|
## Toolbar & Info panel
|
||||||
|
The top toolbar floats as a rounded glass bar (margins all round, `--apg-radius-lg`, `--apg-shadow-md`),
|
||||||
|
matching the sidebar, in embedded and full-screen alike. It stays in normal flow inside `.apg-main`, so
|
||||||
|
`.apg-viewport` begins below it and scroll behaviour is unchanged — no overlap, no manual top-padding.
|
||||||
|
The library-scale segmented is now a compact `.apg-scalemenu` dropdown that reuses the `.apg-menu`
|
||||||
|
popover (menu role, checkmarks, arrow-key nav), which also removes the overlap it used to have with the
|
||||||
|
zoom-out button.
|
||||||
|
The Info panel adds: a real inline `<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
|
## AI providers — all optional, free unless noted
|
||||||
`AIProvider` methods (each optional): `detectObjects`, `detectFaces`, `caption`, `ocr`, `embedImage`,
|
`AIProvider` methods (each optional): `detectObjects`, `detectFaces`, `caption`, `ocr`, `embedImage`,
|
||||||
`embedText`, `generativeEdit`. The SDK ships **no ML deps**; providers are injected by the host:
|
`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
|
## Theming
|
||||||
CSS variables on `.apg[data-theme]`. Themes: `light`, `dark`, `semi-dark` (glass sidebar + light content).
|
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
|
## Security
|
||||||
Per-request **nonce CSP** in `apps/web/src/middleware.ts` (`script-src 'self' 'nonce-…' 'strict-dynamic'`,
|
Per-request **nonce CSP** in `apps/web/src/middleware.ts` (`script-src 'self' 'nonce-…' 'strict-dynamic'`,
|
||||||
|
|||||||
+40
-2
@@ -25,7 +25,7 @@ designed to be **optional via flags** (`features={{ … }}`) — a consuming pro
|
|||||||
|
|
||||||
## 2. Library, viewing & navigation
|
## 2. Library, viewing & navigation
|
||||||
- ✅ Library views: Years / Months / All Photos (Days grouping supported) — **date/time grouping**
|
- ✅ 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
|
- ✅ Adaptive responsive grid + zoom in/out, lazy images
|
||||||
- ✅ **Lightbox** photo viewer (prev/next, keyboard, actions) — 🚧 polish to 100% macOS parity
|
- ✅ **Lightbox** photo viewer (prev/next, keyboard, actions) — 🚧 polish to 100% macOS parity
|
||||||
- ✅ **Video player** (native controls in lightbox) — 🚧 macOS-style custom controls/scrubber
|
- ✅ **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
|
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
|
- ✅ **Grid tab = Memories mosaic**: date sections, full-width **auto-sliding hero** + irregular varied tiles
|
||||||
- ✅ **Mini-map** in the photo info panel
|
- ✅ **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)
|
- Note: demo seed intentionally locates ~half the photos; real photos populate via EXIF GPS (§4)
|
||||||
|
|
||||||
## 6. Editor (photo & video)
|
## 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.
|
> From a full feature audit vs. macOS Photos. HIGH = most-requested / highest value.
|
||||||
- ✅ **Custom video player** (glass scrubber, buffered/played, volume, PiP, fullscreen, keyboard)
|
- ✅ **Custom video player** (glass scrubber, buffered/played, volume, PiP, fullscreen, keyboard)
|
||||||
- ⬜ **HIGH — Lightbox zoom/pan** (scroll / double-click / pinch) for detail viewing
|
- ⬜ **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 — click‑to‑edit **caption** + multi‑line **`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)
|
- ⬜ **HIGH — Slideshow / Memories playback** (auto-advance + Ken Burns transitions)
|
||||||
- ⬜ MED — Lightbox **filmstrip** of adjacent media; **timeline scrubber** to jump by date
|
- ⬜ MED — Lightbox **filmstrip** of adjacent media; **timeline scrubber** to jump by date
|
||||||
- ⬜ MED — **Keyboard-shortcut help** modal (`?`); album **drag-to-reorder**
|
- ⬜ 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`
|
- ✅ Docs: README + `docs/SETUP.md` + `docs/ARCHITECTURE.md` + this roadmap + project-local `CLAUDE.md`
|
||||||
- ✅ Testing: Playwright e2e (manual runs); ⬜ automated CI suite
|
- ✅ 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)
|
## Web-meaningful UI (done 2026-06-29)
|
||||||
- ✅ "All Projects / App Store" (iOS-only) → **"All Albums"** overview (My Albums + Create + Recent Days).
|
- ✅ "All Projects / App Store" (iOS-only) → **"All Albums"** overview (My Albums + Create + Recent Days).
|
||||||
- ✅ Sidebar Albums section: All Albums + New Album + user albums (indented).
|
- ✅ Sidebar Albums section: All Albums + New Album + user albums (indented).
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ Copy `.env.example` → `apps/web/.env.local` (gitignored — never commit). Key
|
|||||||
GEMINI_API_KEY=your-key
|
GEMINI_API_KEY=your-key
|
||||||
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
|
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=...
|
# NEXT_PUBLIC_SUPABASE_URL=...
|
||||||
# SUPABASE_SERVICE_ROLE_KEY=... # server-side only
|
# SUPABASE_SERVICE_ROLE_KEY=... # server-side only
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,6 +13,269 @@ export default () => <PhotoGallery photos={myPhotos} theme="system" />;
|
|||||||
See the [repository README](../../README.md) for full documentation, props, adapters and the
|
See the [repository README](../../README.md) for full documentation, props, adapters and the
|
||||||
AI-provider interface.
|
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 1000–1300 z-indexes now stack *inside* it:
|
||||||
|
above the gallery's content and, through 1400, above the host's chrome.
|
||||||
|
|
||||||
|
Headless control lives on the store: `fullscreen`, `setFullscreen(next)`, `toggleFullscreen()`.
|
||||||
|
`defaultFullscreen` only seeds the initial value; the user's toggle owns it afterwards.
|
||||||
|
|
||||||
|
### Server-backed Recently Deleted lock
|
||||||
|
|
||||||
|
By default the Recently Deleted lock is a password hash in `localStorage` — device-local, and invisible
|
||||||
|
to your backend. Pass a `lockProvider` and the SDK delegates every lock operation to it instead, so the
|
||||||
|
lock belongs to the *user* and follows them across devices. The localStorage path is untouched when no
|
||||||
|
provider is given.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Album, MediaItem, Person } from '../types';
|
import type { Album, AlbumId, MediaId, MediaItem, Person, PersonId } from '../types';
|
||||||
|
|
||||||
export interface PersistedState {
|
export interface PersistedState {
|
||||||
media: MediaItem[];
|
media: MediaItem[];
|
||||||
@@ -13,6 +13,42 @@ export interface PersistedState {
|
|||||||
version: number;
|
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:
|
* Storage adapter contract. Implement this to back the gallery with anything:
|
||||||
* localStorage (default), IndexedDB, a REST/GraphQL API, S3, Postgres, etc.
|
* 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. */
|
/** Persist a binary blob and return a stable URL to it. */
|
||||||
putBlob?(id: string, blob: Blob): Promise<string>;
|
putBlob?(id: string, blob: Blob): Promise<string>;
|
||||||
clear?(): Promise<void>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,22 @@ import { ViewRouter } from './ViewRouter';
|
|||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const api = useGalleryStoreApi();
|
const api = useGalleryStoreApi();
|
||||||
const sidebarOpen = useGallery((s) => s.sidebarOpen);
|
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();
|
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.
|
// Collapse the sidebar by default on phones; expand on larger screens.
|
||||||
const lastMobile = useRef<boolean | null>(null);
|
const lastMobile = useRef<boolean | null>(null);
|
||||||
@@ -26,15 +40,17 @@ export function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar />
|
{showSidebar ? <Sidebar /> : null}
|
||||||
|
{showSidebar ? (
|
||||||
<div
|
<div
|
||||||
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
|
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
onClick={() => api.getState().setSidebar(false)}
|
onClick={() => api.getState().setSidebar(false)}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<div className="apg-main">
|
<div className="apg-main">
|
||||||
<TopToolbar />
|
{showToolbar ? <TopToolbar /> : null}
|
||||||
<div className="apg-viewport">
|
<div className="apg-viewport">
|
||||||
<ViewRouter />
|
<ViewRouter />
|
||||||
<SelectionBar />
|
<SelectionBar />
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export function Camera() {
|
|||||||
duration,
|
duration,
|
||||||
location,
|
location,
|
||||||
exif,
|
exif,
|
||||||
|
// Stamp who captured this (display identity) when the host supplies a user.
|
||||||
|
uploadedBy: api.getState().config.currentUser,
|
||||||
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
|
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
|
||||||
});
|
});
|
||||||
api.getState().addMedia([item]);
|
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) {
|
function subscribe(cb: () => void) {
|
||||||
listeners.add(cb);
|
listeners.add(cb);
|
||||||
return () => listeners.delete(cb);
|
return () => listeners.delete(cb);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { sourceLabel } from '../lib/classify';
|
import { sourceLabel } from '../lib/classify';
|
||||||
import { editFilterCss, editTransformCss } from '../lib/edits';
|
import { editFilterCss, editTransformCss } from '../lib/edits';
|
||||||
@@ -9,7 +9,7 @@ import { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from
|
|||||||
import { Icon } from '../icons';
|
import { Icon } from '../icons';
|
||||||
import { useAIProvider } from './aiContext';
|
import { useAIProvider } from './aiContext';
|
||||||
import { useGallery, useGalleryStoreApi } from '../store/context';
|
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||||||
import type { MediaItem } from '../types';
|
import type { GalleryUser, GeoLocation, MediaItem } from '../types';
|
||||||
|
|
||||||
export function InfoPanel() {
|
export function InfoPanel() {
|
||||||
const api = useGalleryStoreApi();
|
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__empty">Select a single photo to see its details.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="apg-info__body">
|
<div className="apg-info__body">
|
||||||
|
{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} />
|
<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__name">{item.name}</div>
|
||||||
<div className="apg-info__sub">
|
<div className="apg-info__sub">
|
||||||
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
|
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
|
||||||
</div>
|
</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 ? (
|
{item.kind === 'image' && !item.analyzedAt ? (
|
||||||
<div className="apg-info__analyzing">
|
<div className="apg-info__analyzing">
|
||||||
<span className="apg-info__spinner" aria-hidden />
|
<span className="apg-info__spinner" aria-hidden />
|
||||||
@@ -114,21 +146,18 @@ export function InfoPanel() {
|
|||||||
<Row
|
<Row
|
||||||
label="Location"
|
label="Location"
|
||||||
value={
|
value={
|
||||||
|
item.location.formatted ??
|
||||||
item.location.place ??
|
item.location.place ??
|
||||||
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
|
`${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
|
<MiniMap
|
||||||
lat={item.location.lat}
|
lat={item.location.lat}
|
||||||
lng={item.location.lng}
|
lng={item.location.lng}
|
||||||
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||||
/>
|
/>
|
||||||
<AddressLookup
|
<AddressBlock
|
||||||
lat={item.location.lat}
|
item={item}
|
||||||
lng={item.location.lng}
|
|
||||||
onOpenMap={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
onOpenMap={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
|
<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">
|
<span className="apg-version__time">
|
||||||
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
|
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
|
||||||
</span>
|
</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>
|
</span>
|
||||||
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
|
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -232,8 +273,11 @@ function Versions({ item }: { item: MediaItem }) {
|
|||||||
/** Threaded comments on a photo/video. */
|
/** Threaded comments on a photo/video. */
|
||||||
function Comments({ item }: { item: MediaItem }) {
|
function Comments({ item }: { item: MediaItem }) {
|
||||||
const api = useGalleryStoreApi();
|
const api = useGalleryStoreApi();
|
||||||
|
const currentUser = useGallery((s) => s.config.currentUser);
|
||||||
const comments = item.comments ?? [];
|
const comments = item.comments ?? [];
|
||||||
const [text, setText] = useState('');
|
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(() => {
|
const [author, setAuthor] = useState(() => {
|
||||||
if (typeof window === 'undefined') return 'You';
|
if (typeof window === 'undefined') return 'You';
|
||||||
return window.localStorage.getItem('apg:comment-author') || 'You';
|
return window.localStorage.getItem('apg:comment-author') || 'You';
|
||||||
@@ -285,6 +329,12 @@ function Comments({ item }: { item: MediaItem }) {
|
|||||||
const post = () => {
|
const post = () => {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
if (!t) return;
|
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';
|
const name = author.trim() || 'You';
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem('apg:comment-author', name);
|
window.localStorage.setItem('apg:comment-author', name);
|
||||||
@@ -307,11 +357,19 @@ function Comments({ item }: { item: MediaItem }) {
|
|||||||
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
|
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="apg-comments">
|
<ul className="apg-comments">
|
||||||
{comments.map((c) => (
|
{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">
|
<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>
|
<div className="apg-comment__avatar" aria-hidden>
|
||||||
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
|
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="apg-comment__body">
|
<div className="apg-comment__body">
|
||||||
<div className="apg-comment__meta">
|
<div className="apg-comment__meta">
|
||||||
<span className="apg-comment__author">{c.author ?? 'You'}</span>
|
<span className="apg-comment__author">{c.author ?? 'You'}</span>
|
||||||
@@ -321,6 +379,7 @@ function Comments({ item }: { item: MediaItem }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="apg-comment__text">{c.text}</div>
|
<div className="apg-comment__text">{c.text}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canDelete ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="apg-comment__delete"
|
className="apg-comment__delete"
|
||||||
@@ -329,12 +388,26 @@ function Comments({ item }: { item: MediaItem }) {
|
|||||||
>
|
>
|
||||||
<Icon name="close" size={13} />
|
<Icon name="close" size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="apg-comment-form">
|
<div className="apg-comment-form">
|
||||||
|
{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
|
<input
|
||||||
className="apg-comment-form__author"
|
className="apg-comment-form__author"
|
||||||
value={author}
|
value={author}
|
||||||
@@ -343,6 +416,7 @@ function Comments({ item }: { item: MediaItem }) {
|
|||||||
aria-label="Your name"
|
aria-label="Your name"
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
className="apg-comment-form__input"
|
className="apg-comment-form__input"
|
||||||
value={text}
|
value={text}
|
||||||
@@ -452,68 +526,253 @@ function Chips({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cross-instance guards so re-opening a panel (or a second panel) never double-
|
||||||
* "Show address" button → reverse-geocodes the GPS coords to a human-readable
|
// fetches the same item: ids currently in flight, and ids that already failed
|
||||||
* address (free OpenStreetMap Nominatim). Clicking the resolved address opens the
|
// (so the auto-fetch doesn't loop — the user must press Retry).
|
||||||
* full Map at that location.
|
const geocodeInFlight = new Set<string>();
|
||||||
*/
|
const geocodeFailed = new Set<string>();
|
||||||
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');
|
|
||||||
|
|
||||||
const lookup = async () => {
|
/** Shape of Nominatim's `address` object (addressdetails=1) — all fields optional. */
|
||||||
setState('loading');
|
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 {
|
try {
|
||||||
const res = await fetch(
|
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' } },
|
{ headers: { Accept: 'application/json' } },
|
||||||
);
|
);
|
||||||
const data = (await res.json()) as { display_name?: string };
|
const data = (await res.json()) as { display_name?: string; address?: NominatimAddress };
|
||||||
if (data?.display_name) {
|
const a = data.address ?? {};
|
||||||
setAddress(data.display_name);
|
if (data.display_name) {
|
||||||
setState('idle');
|
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 {
|
} else {
|
||||||
setState('error');
|
geocodeFailed.add(item.id);
|
||||||
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<button
|
<div className="apg-address">
|
||||||
type="button"
|
<button type="button" className="apg-address__line" onClick={onOpenMap} title="Open in Map">
|
||||||
onClick={onOpenMap}
|
<Icon name="map" size={14} />
|
||||||
title="Open in Map"
|
<span>{loc.formatted}</span>
|
||||||
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>
|
</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 (
|
return (
|
||||||
<button
|
<div className="apg-address">
|
||||||
type="button"
|
<div className="apg-info__coords">
|
||||||
className="apg-btn apg-btn--small"
|
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||||
onClick={() => void lookup()}
|
</div>
|
||||||
disabled={state === 'loading'}
|
{status === 'loading' ? (
|
||||||
style={{ marginTop: 6 }}
|
<div className="apg-info__hint" aria-live="polite">
|
||||||
>
|
Looking up address…
|
||||||
{state === 'loading' ? 'Looking up…' : state === 'error' ? 'Retry address' : 'Show 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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export function closeModal() {
|
|||||||
emit();
|
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) {
|
function subscribe(cb: () => void) {
|
||||||
listeners.add(cb);
|
listeners.add(cb);
|
||||||
return () => listeners.delete(cb);
|
return () => listeners.delete(cb);
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import { normalizeMediaItem, type MediaInput } from '../lib/media';
|
|||||||
import { GalleryStoreContext, useGallery, useGalleryStoreApi } from '../store/context';
|
import { GalleryStoreContext, useGallery, useGalleryStoreApi } from '../store/context';
|
||||||
import {
|
import {
|
||||||
createGalleryStore,
|
createGalleryStore,
|
||||||
|
DEFAULT_CHROME,
|
||||||
DEFAULT_FEATURES,
|
DEFAULT_FEATURES,
|
||||||
type GalleryConfig,
|
type GalleryConfig,
|
||||||
type GalleryFeatures,
|
type GalleryFeatures,
|
||||||
type GalleryStore,
|
type GalleryStore,
|
||||||
type ThemeTokens,
|
type ThemeTokens,
|
||||||
} from '../store/store';
|
} from '../store/store';
|
||||||
import type { Album, MediaItem, ThemePreference } from '../types';
|
import type { Album, GalleryUser, MediaItem, ThemePreference, ViewId } from '../types';
|
||||||
import { AIAnalyzer } from './AIAnalyzer';
|
import { AIAnalyzer } from './AIAnalyzer';
|
||||||
import { SemanticSearch } from './SemanticSearch';
|
import { SemanticSearch } from './SemanticSearch';
|
||||||
import { AIProviderContext } from './aiContext';
|
import { AIProviderContext } from './aiContext';
|
||||||
@@ -44,9 +45,42 @@ export interface PhotoGalleryProps {
|
|||||||
/** Per-theme color / gradient / radius overrides (mapped to CSS variables). */
|
/** Per-theme color / gradient / radius overrides (mapped to CSS variables). */
|
||||||
themeTokens?: ThemeTokens;
|
themeTokens?: ThemeTokens;
|
||||||
features?: Partial<GalleryFeatures>;
|
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;
|
showWindowChrome?: boolean;
|
||||||
title?: string;
|
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;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
@@ -65,6 +99,14 @@ export function PhotoGallery(props: PhotoGalleryProps) {
|
|||||||
features,
|
features,
|
||||||
showWindowChrome = false,
|
showWindowChrome = false,
|
||||||
title = 'Photos',
|
title = 'Photos',
|
||||||
|
currentUser,
|
||||||
|
shareBaseUrl,
|
||||||
|
chrome,
|
||||||
|
hiddenViews,
|
||||||
|
keyboardShortcuts = true,
|
||||||
|
embedded = false,
|
||||||
|
lockProvider,
|
||||||
|
defaultFullscreen = false,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
onReady,
|
onReady,
|
||||||
@@ -80,16 +122,67 @@ export function PhotoGallery(props: PhotoGalleryProps) {
|
|||||||
[ai],
|
[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(
|
const config: GalleryConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
features: { ...DEFAULT_FEATURES, ...features },
|
features: { ...DEFAULT_FEATURES, ...features },
|
||||||
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
|
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
|
||||||
borderRadius: borderRadius ?? 10,
|
borderRadius: borderRadius ?? 10,
|
||||||
showWindowChrome,
|
showWindowChrome: chromeTitlebar,
|
||||||
title,
|
title,
|
||||||
themeTokens,
|
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.
|
// Create the store exactly once for this gallery instance.
|
||||||
@@ -109,6 +202,12 @@ export function PhotoGallery(props: PhotoGalleryProps) {
|
|||||||
store.getState().setTheme(theme);
|
store.getState().setTheme(theme);
|
||||||
}, [store, 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 (
|
return (
|
||||||
<GalleryStoreContext.Provider value={store}>
|
<GalleryStoreContext.Provider value={store}>
|
||||||
<GalleryRoot
|
<GalleryRoot
|
||||||
@@ -138,7 +237,9 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
|
|||||||
const accent = useGallery((s) => s.config.accentColor);
|
const accent = useGallery((s) => s.config.accentColor);
|
||||||
const radius = useGallery((s) => s.config.borderRadius);
|
const radius = useGallery((s) => s.config.borderRadius);
|
||||||
const tokens = useGallery((s) => s.config.themeTokens);
|
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 aiEnabled = useGallery((s) => s.config.features.ai);
|
||||||
const cameraEnabled = useGallery((s) => s.config.features.camera);
|
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) => {
|
const set = (v: string | undefined, name: string) => {
|
||||||
if (v && !CSS_UNSAFE.test(v)) tokenVars[name] = v;
|
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');
|
||||||
set(bg, '--apg-bg-content');
|
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.
|
// 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(
|
||||||
set(dark ? tokens.textDark : tokens.textLight, '--apg-text');
|
resolvedTheme === 'semi-dark'
|
||||||
if (typeof tokens.sidebarRadius === 'number') tokenVars['--apg-sidebar-radius'] = `${tokens.sidebarRadius}px`;
|
? 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 = {
|
const rootStyle: CSSProperties = {
|
||||||
['--apg-accent' as string]: accent,
|
['--apg-accent' as string]: accent,
|
||||||
['--apg-radius' as string]: `${radius}px`,
|
['--apg-radius' as string]: `${radius}px`,
|
||||||
['--apg-radius-sm' as string]: `${Math.max(2, Math.round(radius * 0.6))}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-lg' as string]: `${Math.round(radius * 1.4)}px`,
|
||||||
['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`,
|
['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`,
|
||||||
|
['--apg-overlay-top' as string]: overlayTop,
|
||||||
...tokenVars,
|
...tokenVars,
|
||||||
...style,
|
...style,
|
||||||
};
|
};
|
||||||
@@ -207,7 +359,14 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
|
|||||||
return (
|
return (
|
||||||
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
|
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
|
||||||
<div
|
<div
|
||||||
className={['apg', className].filter(Boolean).join(' ')}
|
className={[
|
||||||
|
'apg',
|
||||||
|
embedded ? 'apg--embedded' : '',
|
||||||
|
fullscreen ? 'apg--fullscreen' : '',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
data-theme={resolvedTheme}
|
data-theme={resolvedTheme}
|
||||||
style={rootStyle}
|
style={rootStyle}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -116,8 +116,15 @@ export function Sidebar() {
|
|||||||
const api = useGalleryStoreApi();
|
const api = useGalleryStoreApi();
|
||||||
const sidebarOpen = useGallery((s) => s.sidebarOpen);
|
const sidebarOpen = useGallery((s) => s.sidebarOpen);
|
||||||
const albums = useGallery((s) => s.albums);
|
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.
|
// 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 lockIcon: IconName = locked ? 'lock' : 'unlock';
|
||||||
const [sharingOpen, setSharingOpen] = useState(true);
|
const [sharingOpen, setSharingOpen] = useState(true);
|
||||||
const [albumsOpen, setAlbumsOpen] = 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.
|
// Sidebar lock toggle: no password → set one; unlocked → re-lock; locked → go unlock.
|
||||||
const toggleLock = () => {
|
const toggleLock = () => {
|
||||||
const s = api.getState();
|
const s = api.getState();
|
||||||
if (!s.lock.hash) openSecuritySettings();
|
if (!s.lockConfigured) openSecuritySettings();
|
||||||
else if (s.lockUnlocked) s.relock();
|
else if (s.lockUnlocked) s.relock();
|
||||||
else s.setView('recently-deleted');
|
else s.setView('recently-deleted');
|
||||||
};
|
};
|
||||||
@@ -207,14 +214,32 @@ export function Sidebar() {
|
|||||||
<Row icon="library" label="Library" view="library" />
|
<Row icon="library" label="Library" view="library" />
|
||||||
<Row icon="collections" label="Collections" view="collections" />
|
<Row icon="collections" label="Collections" view="collections" />
|
||||||
|
|
||||||
|
{anyShown(
|
||||||
|
'favourites',
|
||||||
|
'recently-saved',
|
||||||
|
'map',
|
||||||
|
'videos',
|
||||||
|
'screenshots',
|
||||||
|
'sys:documents',
|
||||||
|
'people',
|
||||||
|
'recently-deleted',
|
||||||
|
) ? (
|
||||||
<SectionLabel>Pinned</SectionLabel>
|
<SectionLabel>Pinned</SectionLabel>
|
||||||
<Row icon="heart" label="Favourites" view="favourites" />
|
) : null}
|
||||||
|
{shown('favourites') ? <Row icon="heart" label="Favourites" view="favourites" /> : null}
|
||||||
|
{shown('recently-saved') ? (
|
||||||
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
||||||
<Row icon="map" label="Map" view="map" />
|
) : null}
|
||||||
<Row icon="video" label="Videos" view="videos" />
|
{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" />
|
<Row icon="screenshot" label="Screenshots" view="screenshots" />
|
||||||
|
) : null}
|
||||||
|
{shown('sys:documents') ? (
|
||||||
<Row icon="document" label="Documents" view="sys:documents" />
|
<Row icon="document" label="Documents" view="sys:documents" />
|
||||||
<Row icon="person-circle" label="People" view="people" />
|
) : null}
|
||||||
|
{shown('people') ? <Row icon="person-circle" label="People" view="people" /> : null}
|
||||||
|
{shown('recently-deleted') ? (
|
||||||
<Row
|
<Row
|
||||||
icon="trash"
|
icon="trash"
|
||||||
label="Recently Deleted"
|
label="Recently Deleted"
|
||||||
@@ -223,6 +248,7 @@ export function Sidebar() {
|
|||||||
onTrailingClick={toggleLock}
|
onTrailingClick={toggleLock}
|
||||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -304,7 +330,10 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
|
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
|
||||||
|
|
||||||
|
{anyShown('recently-deleted', 'duplicates', 'versions', 'map') ? (
|
||||||
<SectionLabel>Utilities</SectionLabel>
|
<SectionLabel>Utilities</SectionLabel>
|
||||||
|
) : null}
|
||||||
|
{shown('recently-deleted') ? (
|
||||||
<Row
|
<Row
|
||||||
icon="trash"
|
icon="trash"
|
||||||
label="Recently Deleted"
|
label="Recently Deleted"
|
||||||
@@ -314,9 +343,10 @@ export function Sidebar() {
|
|||||||
onTrailingClick={toggleLock}
|
onTrailingClick={toggleLock}
|
||||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||||
/>
|
/>
|
||||||
<Row icon="duplicates" label="Duplicates" view="duplicates" />
|
) : null}
|
||||||
<Row icon="clock" label="Versions & Audit" view="versions" />
|
{shown('duplicates') ? <Row icon="duplicates" label="Duplicates" view="duplicates" /> : null}
|
||||||
<Row icon="map" label="Map" view="map" noActive />
|
{shown('versions') ? <Row icon="clock" label="Versions & Audit" view="versions" /> : null}
|
||||||
|
{shown('map') ? <Row icon="map" label="Map" view="map" noActive /> : null}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ const GRID_VIEWS = new Set<string>([
|
|||||||
'search',
|
'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 }> = [
|
const FILTER_OPTIONS: Array<{ value: GridFilter; label: string }> = [
|
||||||
{ value: 'all', label: 'All Items' },
|
{ value: 'all', label: 'All Items' },
|
||||||
{ value: 'favourites', label: 'Favourites' },
|
{ value: 'favourites', label: 'Favourites' },
|
||||||
@@ -72,8 +78,11 @@ export function TopToolbar() {
|
|||||||
const zoomIndex = useGallery((s) => s.zoomIndex);
|
const zoomIndex = useGallery((s) => s.zoomIndex);
|
||||||
const features = useGallery((s) => s.config.features);
|
const features = useGallery((s) => s.config.features);
|
||||||
const theme = useGallery((s) => s.theme);
|
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 aiStatus = useGallery((s) => s.aiStatus);
|
||||||
const infoOpen = useGallery((s) => s.infoOpen);
|
const infoOpen = useGallery((s) => s.infoOpen);
|
||||||
|
const fullscreen = useGallery((s) => s.fullscreen);
|
||||||
const selectionCount = useGallery((s) => s.selection.size);
|
const selectionCount = useGallery((s) => s.selection.size);
|
||||||
|
|
||||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
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 openMoreMenu = (e: React.MouseEvent) => {
|
||||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
const state = api.getState();
|
const state = api.getState();
|
||||||
@@ -116,6 +142,8 @@ export function TopToolbar() {
|
|||||||
...(features.import
|
...(features.import
|
||||||
? [{ label: 'Import…', icon: 'download' as const, onClick: () => openUploadModal(currentAlbumId) }]
|
? [{ label: 'Import…', icon: 'download' as const, onClick: () => openUploadModal(currentAlbumId) }]
|
||||||
: []),
|
: []),
|
||||||
|
...(showThemeSwitcher
|
||||||
|
? [
|
||||||
{ type: 'separator' as const },
|
{ type: 'separator' as const },
|
||||||
{ type: 'label' as const, label: 'Appearance' },
|
{ type: 'label' as const, label: 'Appearance' },
|
||||||
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
|
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
|
||||||
@@ -127,10 +155,12 @@ export function TopToolbar() {
|
|||||||
onClick: () => state.setTheme('semi-dark'),
|
onClick: () => state.setTheme('semi-dark'),
|
||||||
},
|
},
|
||||||
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
|
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ type: 'separator' as const },
|
{ type: 'separator' as const },
|
||||||
{
|
{
|
||||||
label: state.lock.hash ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
|
label: state.lockConfigured ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
|
||||||
icon: state.lock.hash ? 'lock' : 'unlock',
|
icon: state.lockConfigured ? 'lock' : 'unlock',
|
||||||
onClick: () => openSecuritySettings(),
|
onClick: () => openSecuritySettings(),
|
||||||
},
|
},
|
||||||
...(view === 'recently-deleted'
|
...(view === 'recently-deleted'
|
||||||
@@ -195,18 +225,22 @@ export function TopToolbar() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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">
|
<div className="apg-toolbar__center">
|
||||||
{view === 'library' || view === 'search' ? (
|
{view === 'library' || view === 'search' ? (
|
||||||
<Segmented<LibraryScale>
|
<button
|
||||||
value={libraryScale}
|
type="button"
|
||||||
onChange={(v) => api.getState().setLibraryScale(v)}
|
className="apg-scalemenu"
|
||||||
options={[
|
aria-haspopup="menu"
|
||||||
{ value: 'years', label: 'Years' },
|
aria-label={`Library scale: ${scaleLabel}`}
|
||||||
{ value: 'months', label: 'Months' },
|
onClick={openScaleMenu}
|
||||||
{ value: 'all', label: 'All Photos' },
|
>
|
||||||
]}
|
<span className="apg-scalemenu__label">{scaleLabel}</span>
|
||||||
/>
|
<Icon name="chevron-down" size={14} />
|
||||||
|
</button>
|
||||||
) : view === 'map' ? (
|
) : view === 'map' ? (
|
||||||
<Segmented<MapMode>
|
<Segmented<MapMode>
|
||||||
value={mapMode}
|
value={mapMode}
|
||||||
@@ -267,6 +301,16 @@ export function TopToolbar() {
|
|||||||
<button type="button" className="apg-iconbtn" aria-label="More" onClick={openMoreMenu}>
|
<button type="button" className="apg-iconbtn" aria-label="More" onClick={openMoreMenu}>
|
||||||
<Icon name="ellipsis" />
|
<Icon name="ellipsis" />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
|
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
|
||||||
@@ -295,7 +339,9 @@ export function TopToolbar() {
|
|||||||
<Icon name="search" size={16} />
|
<Icon name="search" size={16} />
|
||||||
<input
|
<input
|
||||||
aria-label="Search"
|
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}
|
value={searchQuery}
|
||||||
onFocus={() => setSearchExpanded(true)}
|
onFocus={() => setSearchExpanded(true)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function VideoEditor() {
|
|||||||
const [annColor, setAnnColor] = useState('#ff3b30');
|
const [annColor, setAnnColor] = useState('#ff3b30');
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [playhead, setPlayhead] = useState(0);
|
const [playhead, setPlayhead] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
const [selOverlay, setSelOverlay] = useState<string | null>(null);
|
const [selOverlay, setSelOverlay] = useState<string | null>(null);
|
||||||
const provider = useAIProvider();
|
const provider = useAIProvider();
|
||||||
const [denoiseBusy, setDenoiseBusy] = useState(false);
|
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));
|
const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration));
|
||||||
if (v.currentTime >= hi) v.currentTime = lo;
|
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);
|
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]);
|
}, [edits.trim, edits.segments, duration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -398,9 +409,12 @@ export function VideoEditor() {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={item.src}
|
src={item.src}
|
||||||
controls
|
|
||||||
playsInline
|
playsInline
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
|
onClick={() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (v) (v.paused ? v.play() : v.pause());
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '70vh',
|
maxHeight: '70vh',
|
||||||
@@ -476,6 +490,39 @@ export function VideoEditor() {
|
|||||||
onChange={(annotations) => update({ annotations })}
|
onChange={(annotations) => update({ annotations })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="apg-editor__panel apg-scroll">
|
<div className="apg-editor__panel apg-scroll">
|
||||||
|
|||||||
@@ -184,19 +184,39 @@ export function confirmAction(opts: {
|
|||||||
|
|
||||||
function SecuritySettingsModal() {
|
function SecuritySettingsModal() {
|
||||||
const api = useGalleryStoreApi();
|
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 [p1, setP1] = useState('');
|
||||||
const [p2, setP2] = useState('');
|
const [p2, setP2] = useState('');
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
|
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
|
||||||
if (p1 !== p2) return setErr('Passwords do not match.');
|
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();
|
closeModal();
|
||||||
};
|
};
|
||||||
const remove = () => {
|
const remove = async () => {
|
||||||
api.getState().removeLockPassword();
|
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();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,7 +226,11 @@ function SecuritySettingsModal() {
|
|||||||
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
|
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
|
||||||
</div>
|
</div>
|
||||||
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
|
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
|
||||||
{hasPw
|
{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.'
|
? '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).'}
|
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +265,8 @@ function SecuritySettingsModal() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="apg-btn"
|
className="apg-btn"
|
||||||
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
|
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
|
||||||
onClick={remove}
|
disabled={busy}
|
||||||
|
onClick={() => void remove()}
|
||||||
>
|
>
|
||||||
Remove Lock
|
Remove Lock
|
||||||
</button>
|
</button>
|
||||||
@@ -249,7 +274,12 @@ function SecuritySettingsModal() {
|
|||||||
<button type="button" className="apg-btn" onClick={closeModal}>
|
<button type="button" className="apg-btn" onClick={closeModal}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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'}
|
{hasPw ? 'Change' : 'Set Password'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { formatDay } from '../../lib/format';
|
import { formatDay } from '../../lib/format';
|
||||||
import { groupByTime } from '../../lib/grouping';
|
import { groupByTime } from '../../lib/grouping';
|
||||||
import { resolveLabel } from '../../lib/smartAlbums';
|
import { resolveLabel } from '../../lib/smartAlbums';
|
||||||
@@ -102,12 +100,10 @@ export function CollectionsView() {
|
|||||||
const labelAliases = useGallery((s) => s.labelAliases);
|
const labelAliases = useGallery((s) => s.labelAliases);
|
||||||
const live = liveMedia(media);
|
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 userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
|
||||||
const go = (v: ViewId) => () => api.getState().setView(v);
|
const go = (v: ViewId) => () => api.getState().setView(v);
|
||||||
|
|
||||||
const recentDays = groupByTime(live, 'day').slice(0, 8);
|
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()]
|
const objectEntries = [...objectLabelCounts(media, labelAliases).entries()]
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 14);
|
.slice(0, 14);
|
||||||
@@ -143,31 +139,6 @@ export function CollectionsView() {
|
|||||||
return (
|
return (
|
||||||
<div className="apg-scroll">
|
<div className="apg-scroll">
|
||||||
<div className="apg-collections">
|
<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 */}
|
{/* Albums */}
|
||||||
<section className="apg-collections__section">
|
<section className="apg-collections__section">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -236,34 +207,6 @@ export function CollectionsView() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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 */}
|
{/* Shared Albums */}
|
||||||
<section className="apg-collections__section">
|
<section className="apg-collections__section">
|
||||||
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
|
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
|
||||||
@@ -297,36 +240,6 @@ export function CollectionsView() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,16 +10,19 @@ import { liveMedia } from '../../store/selectors';
|
|||||||
import { MediaGrid } from '../MediaGrid';
|
import { MediaGrid } from '../MediaGrid';
|
||||||
|
|
||||||
function Welcome({ onImport }: { onImport: () => void }) {
|
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 }> = [
|
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: '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 (
|
return (
|
||||||
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
|
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
|
||||||
<div className="apg-empty__title">Welcome to Photos</div>
|
<div className="apg-empty__title">Welcome to {title}</div>
|
||||||
<div className="apg-empty__subtitle">To get started with Photos, do any of the following:</div>
|
<div className="apg-empty__subtitle">To get started, do any of the following:</div>
|
||||||
<div className="apg-empty__hints">
|
<div className="apg-empty__hints">
|
||||||
{hints.map((h, i) => (
|
{hints.map((h, i) => (
|
||||||
<div className="apg-empty__hint" key={i}>
|
<div className="apg-empty__hint" key={i}>
|
||||||
|
|||||||
@@ -1,15 +1,185 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { MAP_TILES } from '../../constants';
|
import { MAP_TILES } from '../../constants';
|
||||||
import { groupByTime } from '../../lib/grouping';
|
import { groupByTime } from '../../lib/grouping';
|
||||||
import { Icon } from '../../icons';
|
import { Icon } from '../../icons';
|
||||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
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 { MediaGrid } from '../MediaGrid';
|
||||||
import { MosaicGrid } from './MosaicGrid';
|
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() {
|
export function MapView() {
|
||||||
const api = useGalleryStoreApi();
|
const api = useGalleryStoreApi();
|
||||||
const mapMode = useGallery((s) => s.mapMode);
|
const mapMode = useGallery((s) => s.mapMode);
|
||||||
@@ -23,6 +193,19 @@ export function MapView() {
|
|||||||
const [sheetH, setSheetH] = useState(0.5);
|
const [sheetH, setSheetH] = useState(0.5);
|
||||||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
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) => {
|
const onHandleDown = (e: React.PointerEvent) => {
|
||||||
dragRef.current = { startY: e.clientY, startH: sheetH };
|
dragRef.current = { startY: e.clientY, startH: sheetH };
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
@@ -44,16 +227,54 @@ export function MapView() {
|
|||||||
const tileRef = useRef<any>(null);
|
const tileRef = useRef<any>(null);
|
||||||
const markersRef = useRef<any>(null);
|
const markersRef = useRef<any>(null);
|
||||||
const leafletRef = 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
|
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
|
||||||
// are created once, so closing over state directly would go stale).
|
// are created once, so closing over state directly would go stale).
|
||||||
const locatedRef = useRef(located);
|
const locatedRef = useRef(located);
|
||||||
locatedRef.current = 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>(() => {});
|
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
|
||||||
openClusterRef.current = (c) => {
|
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);
|
setCluster(c);
|
||||||
setSheetH(0.5); // reset to half height each time a pin is opened
|
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.
|
// Create the map once when entering a map tile mode.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
|
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
|
||||||
@@ -91,16 +312,19 @@ export function MapView() {
|
|||||||
// Tear the map down when leaving map tile modes.
|
// Tear the map down when leaving map tile modes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapMode === 'grid' && mapRef.current) {
|
if (mapMode === 'grid' && mapRef.current) {
|
||||||
|
clearTipTimers();
|
||||||
mapRef.current.remove();
|
mapRef.current.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
tileRef.current = null;
|
tileRef.current = null;
|
||||||
markersRef.current = null;
|
markersRef.current = null;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mapMode]);
|
}, [mapMode]);
|
||||||
|
|
||||||
// Destroy on unmount.
|
// Destroy on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTipTimers();
|
||||||
if (mapRef.current) {
|
if (mapRef.current) {
|
||||||
mapRef.current.remove();
|
mapRef.current.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
@@ -108,6 +332,7 @@ export function MapView() {
|
|||||||
markersRef.current = null;
|
markersRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addTiles = () => {
|
const addTiles = () => {
|
||||||
@@ -131,6 +356,8 @@ export function MapView() {
|
|||||||
if (!L || !map) return;
|
if (!L || !map) return;
|
||||||
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
|
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
|
||||||
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
|
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();
|
markersRef.current.clearLayers();
|
||||||
|
|
||||||
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
|
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
|
||||||
@@ -140,16 +367,19 @@ export function MapView() {
|
|||||||
|
|
||||||
for (const c of clusters) {
|
for (const c of clusters) {
|
||||||
const cover = c.items[0]!;
|
const cover = c.items[0]!;
|
||||||
|
const multi = c.items.length > 1;
|
||||||
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
|
// 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.
|
// injected; the thumbnail src + count are set via DOM after the marker mounts.
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
className: 'apg-pin-wrap',
|
className: 'apg-pin-wrap',
|
||||||
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
|
// 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:
|
html:
|
||||||
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
|
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
|
||||||
'<span class="apg-pin__count"></span>' +
|
'<span class="apg-pin__count"></span>' +
|
||||||
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
|
'<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>',
|
'<span class="apg-pin__tip-cap"></span></span></span>',
|
||||||
iconSize: [56, 64],
|
iconSize: [56, 64],
|
||||||
iconAnchor: [28, 64],
|
iconAnchor: [28, 64],
|
||||||
@@ -162,8 +392,30 @@ export function MapView() {
|
|||||||
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
|
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
|
||||||
if (img) img.src = thumb;
|
if (img) img.src = thumb;
|
||||||
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
|
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;
|
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) {
|
if (cap) {
|
||||||
// textContent (never innerHTML) — place names are attacker-influencable.
|
// textContent (never innerHTML) — place names are attacker-influencable.
|
||||||
const place = cover.location?.place ?? '';
|
const place = cover.location?.place ?? '';
|
||||||
@@ -172,9 +424,81 @@ export function MapView() {
|
|||||||
}
|
}
|
||||||
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
|
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
|
||||||
if (badge) {
|
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);
|
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));
|
marker.on('click', () => openClusterRef.current(c));
|
||||||
markersRef.current.addLayer(marker);
|
markersRef.current.addLayer(marker);
|
||||||
@@ -200,6 +524,36 @@ export function MapView() {
|
|||||||
}
|
}
|
||||||
}, [mapFocus]);
|
}, [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') {
|
if (mapMode === 'grid') {
|
||||||
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
|
// 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.
|
// 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 }}>
|
<div style={{ fontWeight: 700, fontSize: 16 }}>
|
||||||
{cluster.items[0]?.location?.place ?? 'This location'}
|
{cluster.items[0]?.location?.place ?? 'This location'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
|
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }} aria-live="polite">
|
||||||
{cluster.items.length} photo{cluster.items.length === 1 ? '' : 's'}
|
{filterActive
|
||||||
|
? `${filtered.length} of ${cluster.items.length} photo${
|
||||||
|
cluster.items.length === 1 ? '' : 's'
|
||||||
|
}`
|
||||||
|
: `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -288,10 +646,85 @@ export function MapView() {
|
|||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="apg-map__sheet-body apg-scroll">
|
||||||
{groupByTime(cluster.items, 'day').map((s) => (
|
{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} />
|
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function PeopleView() {
|
|||||||
Finding People…
|
Finding People…
|
||||||
</div>
|
</div>
|
||||||
<div className="apg-empty__subtitle">
|
<div className="apg-empty__subtitle">
|
||||||
Photos creates albums and groups of people and pets found in your library when you are
|
People and pets are grouped automatically as photos are analyzed. Add more photos and
|
||||||
not using the app.
|
they will appear here.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ import { openSecuritySettings } from '../modals';
|
|||||||
export function RecentlyDeletedView() {
|
export function RecentlyDeletedView() {
|
||||||
const api = useGalleryStoreApi();
|
const api = useGalleryStoreApi();
|
||||||
const items = useViewMedia();
|
const items = useViewMedia();
|
||||||
const lockHash = useGallery((s) => s.lock.hash);
|
const lockConfigured = useGallery((s) => s.lockConfigured);
|
||||||
const lockUnlocked = useGallery((s) => s.lockUnlocked);
|
const lockUnlocked = useGallery((s) => s.lockUnlocked);
|
||||||
|
const lockError = useGallery((s) => s.lockError);
|
||||||
const [pw, setPw] = useState('');
|
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.
|
// Locked: a password is set and the user hasn't unlocked this session.
|
||||||
if (lockHash && !lockUnlocked) {
|
if (lockConfigured && !lockUnlocked) {
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const ok = await api.getState().unlockLock(pw);
|
setBusy(true);
|
||||||
if (!ok) setError(true);
|
await api.getState().unlockLock(pw);
|
||||||
|
setBusy(false);
|
||||||
setPw('');
|
setPw('');
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -42,17 +44,26 @@ export function RecentlyDeletedView() {
|
|||||||
style={{ maxWidth: 280 }}
|
style={{ maxWidth: 280 }}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPw(e.target.value);
|
setPw(e.target.value);
|
||||||
setError(false);
|
api.getState().clearLockError();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') void submit();
|
if (e.key === 'Enter') void submit();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{lockError ? (
|
||||||
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>Incorrect password.</div>
|
<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}
|
) : null}
|
||||||
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void submit()}>
|
<button
|
||||||
Unlock
|
type="button"
|
||||||
|
className="apg-btn apg-btn--primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{busy ? 'Checking…' : 'Unlock'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -107,8 +118,8 @@ export function RecentlyDeletedView() {
|
|||||||
style={{ flexShrink: 0, padding: '4px 10px' }}
|
style={{ flexShrink: 0, padding: '4px 10px' }}
|
||||||
onClick={openSecuritySettings}
|
onClick={openSecuritySettings}
|
||||||
>
|
>
|
||||||
<Icon name={lockHash ? 'lock' : 'unlock'} size={13} />
|
<Icon name={lockConfigured ? 'lock' : 'unlock'} size={13} />
|
||||||
{lockHash ? 'Locked' : 'Lock…'}
|
{lockConfigured ? 'Locked' : 'Lock…'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<MediaGrid items={items} />
|
<MediaGrid items={items} />
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function VersionsView() {
|
|||||||
<span className="apg-audit-card__latest">
|
<span className="apg-audit-card__latest">
|
||||||
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
|
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
|
||||||
{formatTime(latest.createdAt)}
|
{formatTime(latest.createdAt)}
|
||||||
|
{latest.author ? ` · ${latest.author}` : ''}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,14 +2,22 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { isContextMenuOpen } from '../components/ContextMenu';
|
||||||
|
import { isModalOpen } from '../components/Modal';
|
||||||
import { useGalleryStoreApi } from '../store/context';
|
import { useGalleryStoreApi } from '../store/context';
|
||||||
import { mediaForView } from '../store/selectors';
|
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();
|
const api = useGalleryStoreApi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
// Never hijack typing in inputs / editable fields.
|
// Never hijack typing in inputs / editable fields.
|
||||||
@@ -50,10 +58,20 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape — clear selection / focus.
|
// Escape — clear selection / focus, then leave full screen.
|
||||||
if (e.key === 'Escape') {
|
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);
|
if (state.objectFocus) state.setObjectFocus(null);
|
||||||
else if (state.selection.size) state.clearSelection();
|
else if (state.selection.size) state.clearSelection();
|
||||||
|
else if (state.fullscreen) state.setFullscreen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter / Space — open the (single) selection in the lightbox.
|
// Enter / Space — open the (single) selection in the lightbox.
|
||||||
@@ -65,5 +83,5 @@ export function useKeyboardShortcuts() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [api]);
|
}, [api, enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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).
|
* Original SF-symbol-inspired icon set drawn from scratch (no Apple assets).
|
||||||
@@ -52,10 +52,13 @@ export type IconName =
|
|||||||
| 'volume'
|
| 'volume'
|
||||||
| 'mute'
|
| 'mute'
|
||||||
| 'expand'
|
| 'expand'
|
||||||
|
| 'maximize'
|
||||||
|
| 'minimize'
|
||||||
| 'pip'
|
| 'pip'
|
||||||
| 'tag'
|
| 'tag'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'pin'
|
| 'pin'
|
||||||
|
| 'pencil'
|
||||||
| 'mic';
|
| 'mic';
|
||||||
|
|
||||||
export interface IconProps extends SVGProps<SVGSVGElement> {
|
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: (
|
mic: (
|
||||||
<>
|
<>
|
||||||
<rect x="9" y="3" width="6" height="11" rx="3" />
|
<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" />
|
<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: (
|
library: (
|
||||||
<>
|
<>
|
||||||
<rect x="3" y="6" width="18" height="13" rx="2.5" />
|
<rect x="3" y="6" width="18" height="13" rx="2.5" />
|
||||||
@@ -321,6 +332,20 @@ const paths: Record<IconName, JSX.Element> = {
|
|||||||
expand: (
|
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" />
|
<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: (
|
pip: (
|
||||||
<>
|
<>
|
||||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
|
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ export type { PhotoGalleryProps } from './components/PhotoGallery';
|
|||||||
|
|
||||||
// Headless store access (for advanced composition / external control)
|
// Headless store access (for advanced composition / external control)
|
||||||
export { useGallery, useGalleryStoreApi, GalleryStoreContext } from './store/context';
|
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 {
|
export type {
|
||||||
GalleryState,
|
GalleryState,
|
||||||
GalleryStore,
|
GalleryStore,
|
||||||
GalleryConfig,
|
GalleryConfig,
|
||||||
GalleryFeatures,
|
GalleryFeatures,
|
||||||
|
GalleryChrome,
|
||||||
ThemeTokens,
|
ThemeTokens,
|
||||||
GridFilter,
|
GridFilter,
|
||||||
|
LockProvider,
|
||||||
} from './store/store';
|
} from './store/store';
|
||||||
export * as selectors from './store/selectors';
|
export * as selectors from './store/selectors';
|
||||||
|
|
||||||
@@ -26,6 +28,10 @@ export type {
|
|||||||
MediaId,
|
MediaId,
|
||||||
MediaKind,
|
MediaKind,
|
||||||
MediaSource,
|
MediaSource,
|
||||||
|
MediaComment,
|
||||||
|
MediaVersion,
|
||||||
|
ShareRecord,
|
||||||
|
GalleryUser,
|
||||||
Album,
|
Album,
|
||||||
AlbumId,
|
AlbumId,
|
||||||
AlbumKind,
|
AlbumKind,
|
||||||
@@ -49,10 +55,15 @@ export type {
|
|||||||
|
|
||||||
// Storage adapters
|
// Storage adapters
|
||||||
export { createLocalStorageAdapter } from './adapters/localStorage';
|
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)
|
// 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';
|
export { cosineSimilarity } from './ai/types';
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export function createMediaItem(input: MediaInput): MediaItem {
|
|||||||
personIds: input.personIds ?? [],
|
personIds: input.personIds ?? [],
|
||||||
location: input.location,
|
location: input.location,
|
||||||
exif: input.exif,
|
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,
|
caption: input.caption,
|
||||||
objects: input.objects,
|
objects: input.objects,
|
||||||
faces: input.faces,
|
faces: input.faces,
|
||||||
@@ -109,7 +113,23 @@ export function normalizeMediaItem(raw: unknown): MediaItem | null {
|
|||||||
const lat = asFiniteNumber(loc.lat);
|
const lat = asFiniteNumber(loc.lat);
|
||||||
const lng = asFiniteNumber(loc.lng);
|
const lng = asFiniteNumber(loc.lng);
|
||||||
if (lat !== undefined && lng !== undefined) {
|
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),
|
poster: safeMediaUrl(r.poster),
|
||||||
name: asString(r.name),
|
name: asString(r.name),
|
||||||
caption: asString(r.caption),
|
caption: asString(r.caption),
|
||||||
|
note: asString(r.note),
|
||||||
tags: asStringArray(r.tags),
|
tags: asStringArray(r.tags),
|
||||||
objectLabels: asStringArray(r.objectLabels),
|
objectLabels: asStringArray(r.objectLabels),
|
||||||
albumIds: asStringArray(r.albumIds),
|
albumIds: asStringArray(r.albumIds),
|
||||||
|
|||||||
@@ -55,9 +55,13 @@ export function searchMedia(items: MediaItem[], query: string): MediaItem[] {
|
|||||||
m.source,
|
m.source,
|
||||||
m.kind,
|
m.kind,
|
||||||
m.caption ?? '',
|
m.caption ?? '',
|
||||||
|
m.note ?? '',
|
||||||
m.ocrText ?? '',
|
m.ocrText ?? '',
|
||||||
m.location?.place ?? '',
|
m.location?.place ?? '',
|
||||||
|
m.location?.formatted ?? '',
|
||||||
m.location?.city ?? '',
|
m.location?.city ?? '',
|
||||||
|
m.location?.state ?? '',
|
||||||
|
m.location?.postcode ?? '',
|
||||||
m.location?.country ?? '',
|
m.location?.country ?? '',
|
||||||
...m.tags,
|
...m.tags,
|
||||||
...m.objectLabels,
|
...m.objectLabels,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { createStore } from 'zustand/vanilla';
|
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 type { AIProvider } from '../ai/types';
|
||||||
import { DEFAULT_ZOOM_INDEX, GRID_ZOOM_STEPS, PET_LABELS, TRASH_RETENTION_MS } from '../constants';
|
import { DEFAULT_ZOOM_INDEX, GRID_ZOOM_STEPS, PET_LABELS, TRASH_RETENTION_MS } from '../constants';
|
||||||
import { clusterFaces } from '../lib/cluster';
|
import { clusterFaces } from '../lib/cluster';
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
Album,
|
Album,
|
||||||
AlbumId,
|
AlbumId,
|
||||||
EditState,
|
EditState,
|
||||||
|
GalleryUser,
|
||||||
LibraryScale,
|
LibraryScale,
|
||||||
MapMode,
|
MapMode,
|
||||||
MediaComment,
|
MediaComment,
|
||||||
@@ -61,24 +62,131 @@ export const DEFAULT_FEATURES: GalleryFeatures = {
|
|||||||
* (content), dark values in dark mode (and the semi-dark sidebar).
|
* (content), dark values in dark mode (and the semi-dark sidebar).
|
||||||
*/
|
*/
|
||||||
export interface ThemeTokens {
|
export interface ThemeTokens {
|
||||||
/** Main app / content background. */
|
/** Main app / content background. → `--apg-bg` + `--apg-bg-content` */
|
||||||
bgLight?: string;
|
bgLight?: string;
|
||||||
bgDark?: string;
|
bgDark?: string;
|
||||||
/** Raised surfaces (cards, elevated panels). */
|
/** Raised surfaces (cards, elevated panels). → `--apg-bg-elevated` */
|
||||||
elevatedLight?: string;
|
elevatedLight?: string;
|
||||||
elevatedDark?: string;
|
elevatedDark?: string;
|
||||||
/** Sidebar glass backdrop. */
|
/** Sidebar glass backdrop. → `--apg-sidebar-bg` */
|
||||||
sidebarBgLight?: string;
|
sidebarBgLight?: string;
|
||||||
sidebarBgDark?: string;
|
sidebarBgDark?: string;
|
||||||
/** Primary text color. */
|
/** Primary text color. → `--apg-text` */
|
||||||
textLight?: string;
|
textLight?: string;
|
||||||
textDark?: string;
|
textDark?: string;
|
||||||
/** Sidebar corner radius in px. */
|
/** Sidebar corner radius in px. → `--apg-sidebar-radius` */
|
||||||
sidebarRadius?: number;
|
sidebarRadius?: number;
|
||||||
/** Accent color (overrides `accentColor`). */
|
/** Accent color (overrides `accentColor`). → `--apg-accent` */
|
||||||
accent?: string;
|
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 {
|
export interface GalleryConfig {
|
||||||
features: GalleryFeatures;
|
features: GalleryFeatures;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
@@ -88,6 +196,28 @@ export interface GalleryConfig {
|
|||||||
title: string;
|
title: string;
|
||||||
/** Optional theme-token overrides mapped to CSS variables at runtime. */
|
/** Optional theme-token overrides mapped to CSS variables at runtime. */
|
||||||
themeTokens?: ThemeTokens;
|
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 {
|
export interface GalleryState {
|
||||||
@@ -149,15 +279,39 @@ export interface GalleryState {
|
|||||||
/** Whether the custom camera is open. */
|
/** Whether the custom camera is open. */
|
||||||
cameraOpen: boolean;
|
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 };
|
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). */
|
/** Whether the lock has been opened this session (in-memory, never persisted). */
|
||||||
lockUnlocked: boolean;
|
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). */
|
/** Share records created on this device (persisted in localStorage). */
|
||||||
shares: ShareRecord[];
|
shares: ShareRecord[];
|
||||||
|
|
||||||
// ---- actions ----
|
// ---- actions ----
|
||||||
init: (adapter: StorageAdapter, ai: AIProvider | null) => Promise<void>;
|
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;
|
addMedia: (items: MediaItem[]) => void;
|
||||||
/** Import File objects: builds MediaItems, uploads blobs to the adapter if it supports it. */
|
/** Import File objects: builds MediaItems, uploads blobs to the adapter if it supports it. */
|
||||||
importFiles: (files: FileList | File[], albumId?: AlbumId) => Promise<MediaId[]>;
|
importFiles: (files: FileList | File[], albumId?: AlbumId) => Promise<MediaId[]>;
|
||||||
@@ -219,9 +373,16 @@ export interface GalleryState {
|
|||||||
|
|
||||||
// Recently Deleted lock
|
// Recently Deleted lock
|
||||||
setLockPassword: (password: string) => Promise<void>;
|
setLockPassword: (password: string) => Promise<void>;
|
||||||
removeLockPassword: () => void;
|
removeLockPassword: () => Promise<void>;
|
||||||
unlockLock: (password: string) => Promise<boolean>;
|
unlockLock: (password: string) => Promise<boolean>;
|
||||||
relock: () => void;
|
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
|
// Sharing
|
||||||
createShare: (scope: ShareRecord['scope'], mediaIds: MediaId[], albumId?: AlbumId) => ShareRecord;
|
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) {
|
export function createGalleryStore(options: CreateStoreOptions) {
|
||||||
// Non-reactive references kept out of state.
|
// Non-reactive references kept out of state.
|
||||||
let adapter: StorageAdapter | null = null;
|
let adapter: StorageAdapter | null = null;
|
||||||
let persistTimer: ReturnType<typeof setTimeout> | 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) => {
|
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 = () => {
|
const persist = () => {
|
||||||
if (!adapter) return;
|
if (!adapter) return;
|
||||||
if (persistTimer) clearTimeout(persistTimer);
|
if (persistTimer) clearTimeout(persistTimer);
|
||||||
persistTimer = setTimeout(() => {
|
persistTimer = setTimeout(() => {
|
||||||
const { media, albums, people, labelAliases, deletedLabels } = get();
|
persistTimer = null;
|
||||||
// Only persist user-owned albums; smart/system albums are regenerated.
|
void runPersist();
|
||||||
const userAlbums = albums.filter((a) => !a.system);
|
|
||||||
void adapter!.save({ media, albums: userAlbums, people, labelAliases, deletedLabels, version: 1 });
|
|
||||||
}, 400);
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -348,8 +642,13 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
aiStatus: { running: false, done: 0, total: 0 },
|
aiStatus: { running: false, done: 0, total: 0 },
|
||||||
infoOpen: false,
|
infoOpen: false,
|
||||||
cameraOpen: 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,
|
lockUnlocked: false,
|
||||||
|
lockError: null,
|
||||||
shares: readShares(),
|
shares: readShares(),
|
||||||
|
|
||||||
async init(a, ai) {
|
async init(a, ai) {
|
||||||
@@ -363,6 +662,15 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
// demo `initialMedia` so a fresh install isn't blank.
|
// demo `initialMedia` so a fresh install isn't blank.
|
||||||
const seed = get().media;
|
const seed = get().media;
|
||||||
const seedEmptyBackend = loaded.media.length === 0 && seed.length > 0;
|
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({
|
set({
|
||||||
media: seedEmptyBackend ? seed : loaded.media,
|
media: seedEmptyBackend ? seed : loaded.media,
|
||||||
albums: [...defaultSystemAlbums(ts), ...loaded.albums.filter((al) => !al.system)],
|
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
|
// Build object smart albums for an already-analyzed library (works even when
|
||||||
// AI is disabled — labels persisted from a prior session still group).
|
// AI is disabled — labels persisted from a prior session still group).
|
||||||
get().syncObjectAlbums();
|
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) {
|
addMedia(items) {
|
||||||
if (!items.length) return;
|
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] }));
|
set((s) => ({ media: [...items, ...s.media] }));
|
||||||
// Auto-file captures/imports into source albums (created on first use).
|
// Auto-file captures/imports into source albums (created on first use).
|
||||||
const categories: Array<[string, string]> = [
|
const categories: Array<[string, string]> = [
|
||||||
@@ -409,14 +742,35 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
async importFiles(files, albumId) {
|
async importFiles(files, albumId) {
|
||||||
const arr = Array.from(files as ArrayLike<File>);
|
const arr = Array.from(files as ArrayLike<File>);
|
||||||
const items: MediaItem[] = [];
|
const items: MediaItem[] = [];
|
||||||
|
// Stamp the importing user (display identity) when the host supplies one.
|
||||||
|
const currentUser = get().config.currentUser;
|
||||||
for (const f of arr) {
|
for (const f of arr) {
|
||||||
// mediaFromFile validates the type (image/video allow-list) and returns
|
// mediaFromFile validates the type (image/video allow-list) and returns
|
||||||
// null for anything else — the backend validation for uploads.
|
// null for anything else — the backend validation for uploads.
|
||||||
const item = await mediaFromFile(f);
|
const item = await mediaFromFile(f);
|
||||||
if (!item) continue;
|
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)
|
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.
|
// Upload to the backend (if the adapter supports blobs) for a durable URL.
|
||||||
if (adapter?.putBlob) {
|
if (!item.storageRef && adapter?.putBlob) {
|
||||||
try {
|
try {
|
||||||
const url = await adapter.putBlob(item.id, f);
|
const url = await adapter.putBlob(item.id, f);
|
||||||
if (url) {
|
if (url) {
|
||||||
@@ -455,6 +809,30 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async uploadBlob(id, blob) {
|
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) {
|
if (adapter?.putBlob) {
|
||||||
try {
|
try {
|
||||||
return await adapter.putBlob(id, blob);
|
return await adapter.putBlob(id, blob);
|
||||||
@@ -542,6 +920,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
|
|
||||||
addVersion(id, patch, changes) {
|
addVersion(id, patch, changes) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const user = get().config.currentUser;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
media: s.media.map((m) => {
|
media: s.media.map((m) => {
|
||||||
if (m.id !== id) return m;
|
if (m.id !== id) return m;
|
||||||
@@ -573,6 +952,10 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
height: patch.height ?? m.height,
|
height: patch.height ?? m.height,
|
||||||
edits: patch.edits,
|
edits: patch.edits,
|
||||||
changes: changes.length ? changes : ['Edited'],
|
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 };
|
return { ...m, ...patch, versions: [...base, newVersion], editedAt: now };
|
||||||
}),
|
}),
|
||||||
@@ -582,6 +965,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
|
|
||||||
restoreVersion(id, versionId) {
|
restoreVersion(id, versionId) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const user = get().config.currentUser;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
media: s.media.map((m) => {
|
media: s.media.map((m) => {
|
||||||
if (m.id !== id || !m.versions) return m;
|
if (m.id !== id || !m.versions) return m;
|
||||||
@@ -598,6 +982,10 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
height: target.height,
|
height: target.height,
|
||||||
edits: target.edits,
|
edits: target.edits,
|
||||||
changes: [`Restored version ${target.version}`],
|
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 {
|
return {
|
||||||
...m,
|
...m,
|
||||||
@@ -617,7 +1005,19 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
addComment(id, text, author) {
|
addComment(id, text, author) {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return;
|
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) => ({
|
set((s) => ({
|
||||||
media: s.media.map((m) =>
|
media: s.media.map((m) =>
|
||||||
m.id === id ? { ...m, comments: [...(m.comments ?? []), comment] } : m,
|
m.id === id ? { ...m, comments: [...(m.comments ?? []), comment] } : m,
|
||||||
@@ -626,6 +1026,16 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
deleteComment(id, commentId) {
|
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) => ({
|
set((s) => ({
|
||||||
media: s.media.map((m) =>
|
media: s.media.map((m) =>
|
||||||
m.id === id ? { ...m, comments: (m.comments ?? []).filter((c) => c.id !== commentId) } : 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) {
|
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);
|
const hash = await hashPassword(password);
|
||||||
if (!hash) return;
|
if (!hash) return;
|
||||||
lsSet(LOCK_KEY, hash);
|
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);
|
lsSet(LOCK_KEY, null);
|
||||||
set({ lock: { hash: null }, lockUnlocked: false });
|
set({ lock: { hash: null }, lockConfigured: false, lockUnlocked: false, lockError: null });
|
||||||
},
|
},
|
||||||
async unlockLock(password) {
|
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);
|
const ok = await verifyPassword(password, get().lock.hash);
|
||||||
if (ok) set({ lockUnlocked: true });
|
set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
|
||||||
return ok;
|
return ok;
|
||||||
},
|
},
|
||||||
relock() {
|
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) {
|
createShare(scope, mediaIds, albumId) {
|
||||||
@@ -1013,6 +1482,8 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
: `${mediaIds.length} Photos`;
|
: `${mediaIds.length} Photos`;
|
||||||
const origin =
|
const origin =
|
||||||
typeof location !== 'undefined' && location.origin ? location.origin : 'https://photos.app';
|
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 = {
|
const share: ShareRecord = {
|
||||||
id: nanoid(12),
|
id: nanoid(12),
|
||||||
token,
|
token,
|
||||||
@@ -1020,7 +1491,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
|||||||
mediaIds,
|
mediaIds,
|
||||||
albumId,
|
albumId,
|
||||||
title,
|
title,
|
||||||
url: `${origin}/gallery?shared=${token}`,
|
url: `${base}?shared=${token}`,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
const next = [share, ...get().shares];
|
const next = [share, ...get().shares];
|
||||||
|
|||||||
@@ -292,29 +292,62 @@
|
|||||||
background: var(--apg-bg-content);
|
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 {
|
.apg-toolbar {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: var(--apg-toolbar-h);
|
height: var(--apg-toolbar-h);
|
||||||
padding: 0 14px;
|
padding: 0 12px;
|
||||||
|
margin: 8px;
|
||||||
background: var(--apg-toolbar-bg);
|
background: var(--apg-toolbar-bg);
|
||||||
backdrop-filter: saturate(180%) blur(24px);
|
backdrop-filter: saturate(180%) blur(24px);
|
||||||
-webkit-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;
|
flex-shrink: 0;
|
||||||
z-index: 20;
|
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 {
|
.apg-toolbar__center {
|
||||||
position: absolute;
|
display: flex;
|
||||||
left: 50%;
|
align-items: center;
|
||||||
transform: translateX(-50%);
|
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 {
|
.apg-iconbtn {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
@@ -359,12 +392,12 @@
|
|||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
.apg-segmented__item--active {
|
.apg-segmented__item--active {
|
||||||
background: var(--apg-bg);
|
background: var(--apg-segmented-active, var(--apg-bg));
|
||||||
box-shadow: var(--apg-shadow-sm);
|
box-shadow: var(--apg-shadow-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.apg[data-theme='dark'] .apg-segmented__item--active {
|
.apg[data-theme='dark'] .apg-segmented__item--active {
|
||||||
background: #636366;
|
background: var(--apg-segmented-active, #636366);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
@@ -395,9 +428,18 @@
|
|||||||
.apg-search:focus-within {
|
.apg-search:focus-within {
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--apg-accent) 32%, transparent);
|
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 {
|
.apg-search__recents {
|
||||||
position: absolute;
|
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;
|
right: 0;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
@@ -515,7 +557,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 6px;
|
bottom: 6px;
|
||||||
right: 7px;
|
right: 7px;
|
||||||
color: #ff3b30;
|
color: var(--apg-tile-fav, #ff3b30);
|
||||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
|
||||||
}
|
}
|
||||||
/* ---- Custom video player (macOS-style) ---- */
|
/* ---- Custom video player (macOS-style) ---- */
|
||||||
@@ -864,6 +906,20 @@
|
|||||||
|
|
||||||
/* ---- Map thumbnail pins (macOS-style) ---- */
|
/* ---- Map thumbnail pins (macOS-style) ---- */
|
||||||
.apg-pin-wrap { background: none !important; border: none !important; }
|
.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 {
|
.apg-pin {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -897,9 +953,12 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: calc(100% + 10px);
|
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;
|
transform-origin: bottom center;
|
||||||
width: 168px;
|
width: 168px;
|
||||||
|
max-width: 70vw;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--apg-menu-bg, #fff);
|
background: var(--apg-menu-bg, #fff);
|
||||||
@@ -913,7 +972,24 @@
|
|||||||
}
|
}
|
||||||
.apg-pin:hover .apg-pin__tip {
|
.apg-pin:hover .apg-pin__tip {
|
||||||
opacity: 1;
|
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 {
|
.apg-pin__tip-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -950,19 +1026,71 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 3px;
|
left: 3px;
|
||||||
bottom: 3px;
|
bottom: 3px;
|
||||||
min-width: 18px;
|
min-width: 19px;
|
||||||
height: 18px;
|
height: 19px;
|
||||||
padding: 0 4px;
|
padding: 0 5px;
|
||||||
border-radius: 9px;
|
border-radius: 10px;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
/* 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;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 18px;
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 19px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
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) ---- */
|
/* ---- Map location sheet (tap a pin → its photos, date-grouped) ---- */
|
||||||
.apg-map__sheet {
|
.apg-map__sheet {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1016,6 +1144,227 @@
|
|||||||
}
|
}
|
||||||
.apg-map__sheet-body { flex: 1; overflow-y: auto; min-height: 0; }
|
.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) ---- */
|
/* ---- Mosaic / Memories grid (Map → Grid tab) ---- */
|
||||||
.apg-mosaic-wrap { padding: 4px 0 28px; }
|
.apg-mosaic-wrap { padding: 4px 0 28px; }
|
||||||
.apg-mosaic-section { padding: 6px 16px 10px; }
|
.apg-mosaic-section { padding: 6px 16px 10px; }
|
||||||
@@ -1094,7 +1443,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1000;
|
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);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1254,13 +1603,30 @@
|
|||||||
.apg-modal__list-item:hover { background: var(--apg-hover); }
|
.apg-modal__list-item:hover { background: var(--apg-hover); }
|
||||||
|
|
||||||
/* ---- Editor ---- */
|
/* ---- 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 { 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 { color: #fff; }
|
||||||
.apg-editor__bar .apg-iconbtn:hover { background: rgba(255,255,255,0.12); }
|
.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__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 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__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__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; }
|
.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 ---- */
|
/* ---- Info panel ---- */
|
||||||
.apg-info {
|
.apg-info {
|
||||||
position: fixed;
|
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;
|
right: 14px;
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
@@ -1496,6 +1863,14 @@
|
|||||||
border-radius: var(--apg-radius);
|
border-radius: var(--apg-radius);
|
||||||
background: var(--apg-bg);
|
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__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__sub { color: var(--apg-text-secondary); font-size: 12px; margin-bottom: 10px; }
|
||||||
.apg-info__row {
|
.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 { list-style: disc; margin: 0 0 8px; padding-left: 16px; font-size: 12px; color: var(--apg-text); }
|
||||||
.apg-version__changes li { padding: 1px 0; }
|
.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; }
|
/* ---- Info panel: "Uploaded by" identity block ---- */
|
||||||
.apg-comment { display: flex; gap: 8px; align-items: flex-start; }
|
.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 {
|
.apg-comment__avatar {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -1624,6 +2164,22 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
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__body { flex: 1; min-width: 0; }
|
||||||
.apg-comment__meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
.apg-comment__meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||||
.apg-comment__author { font-size: 12.5px; font-weight: 600; }
|
.apg-comment__author { font-size: 12.5px; font-weight: 600; }
|
||||||
@@ -1675,7 +2231,7 @@
|
|||||||
.apg-info__analyzing {
|
.apg-info__analyzing {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
font-size: 13px; font-weight: 500; color: var(--apg-accent);
|
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);
|
background: color-mix(in srgb, var(--apg-accent) 10%, transparent);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -1852,3 +2408,76 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 4px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ export interface GeoLocation {
|
|||||||
lng: number;
|
lng: number;
|
||||||
/** Reverse-geocoded human label, e.g. "Mumbai, India". */
|
/** Reverse-geocoded human label, e.g. "Mumbai, India". */
|
||||||
place?: string;
|
place?: string;
|
||||||
|
// ---- full-address fields (filled by reverse geocoding, all optional) ----
|
||||||
|
road?: string;
|
||||||
|
neighbourhood?: string;
|
||||||
|
suburb?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
|
county?: string;
|
||||||
|
state?: string;
|
||||||
|
postcode?: string;
|
||||||
country?: 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). */
|
/** Non-destructive edit adjustments (all normalized -1..1 unless noted). */
|
||||||
@@ -194,6 +204,11 @@ export interface MediaItem {
|
|||||||
kind: MediaKind;
|
kind: MediaKind;
|
||||||
/** Full-resolution source (URL, blob URL, or data URL). */
|
/** Full-resolution source (URL, blob URL, or data URL). */
|
||||||
src: string;
|
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`. */
|
/** Optional smaller thumbnail; falls back to `src`. */
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
/** Optional poster frame for videos. */
|
/** Optional poster frame for videos. */
|
||||||
@@ -230,6 +245,16 @@ export interface MediaItem {
|
|||||||
location?: GeoLocation;
|
location?: GeoLocation;
|
||||||
exif?: Record<string, string | number>;
|
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).
|
// AI-derived metadata (optional, filled lazily by providers).
|
||||||
caption?: string;
|
caption?: string;
|
||||||
/**
|
/**
|
||||||
@@ -280,14 +305,39 @@ export interface MediaVersion {
|
|||||||
changes: string[];
|
changes: string[];
|
||||||
/** Optional user label / note. */
|
/** Optional user label / note. */
|
||||||
note?: string;
|
note?: string;
|
||||||
|
// ---- who created this version (display identity; all optional) ----
|
||||||
|
authorId?: string;
|
||||||
|
author?: string;
|
||||||
|
authorAvatar?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A comment left on a photo/video. */
|
/** A comment left on a photo/video. */
|
||||||
export interface MediaComment {
|
export interface MediaComment {
|
||||||
id: string;
|
id: string;
|
||||||
author?: string;
|
|
||||||
text: string;
|
text: string;
|
||||||
createdAt: number;
|
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';
|
export type AlbumKind = 'user' | 'smart' | 'folder' | 'shared';
|
||||||
|
|||||||
Reference in New Issue
Block a user