first commit

This commit is contained in:
2026-07-18 01:34:52 +05:30
commit d1308bf149
113 changed files with 20593 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Project-local AI memory (`.claude/memory/`)
This folder is the **project-local memory for AI assistants**, committed with the code (not in any
machine's global `~/.claude`). Any developer or Claude/AI account that opens this package should read
these files for full context. Keep them updated as work proceeds.
- [status.md](status.md) — current build status + session log (what's done, what's next).
- [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).
## Hard rules (do not violate)
- **Never `git commit` / `git push`** — the user does that. Never commit secrets.
- API keys live only in `apps/web/.env.local` (gitignored), read **server-side** only.
- Every feature is **optional via `features` flags**. End goal: **backend (Supabase), no localStorage reliance**
(localStorage is the dev fallback only until Supabase creds are provided).
- Prefer **free** tech; if no free option, build our own. If a cloud key (e.g. Gemini) is missing/invalid,
fall back to a **local model** (e.g. `@imgly/background-removal` for remove-bg).
- UI must be **meaningful for a WEB gallery** (not iOS) — e.g. no "App Store"; "Albums" not "Projects".
+46
View File
@@ -0,0 +1,46 @@
# Requirements backlog (complete)
Status: ✅ done · 🚧 in progress · ⬜ planned · 🔑 needs key/creds. Cross-ref `/docs/ROADMAP.md`.
## Done (Phases 13)
- ✅ macOS-Photos UI: sidebar, toolbar, Library (Years/Months/All), Collections, Map (map/satellite/grid),
People, Recently Deleted (locked), lightbox, light/dark/**semi-dark**, responsive to 280px, a11y, nonce CSP.
- ✅ Albums (CRUD/nested/smart), source auto-classify, recycle bin, duplicates, multi-select, import (file+drag),
export, **EXIF** (GPS/date/camera) on import.
- ✅ Object detection (TF.js, free) → tags/search/find-similar/**Objects browser**/object focus.
- ✅ Annotations (rect/oval/line/arrow/**double-arrow measurement**/text/freehand + colors) in editor + lightbox.
- ✅ Info panel (full metadata + **mini-map**). Themes menu + `accentColor`/`borderRadius` props.
- ✅ Gemini generative editor (Remove BG/Restore/Colorize/Replace Sky/prompt) — 🔑 needs billed key (free=429).
## Now / next (in user's priority order)
1. 🚧 **Custom camera** (#1): getUserMedia preview; capture photo; record video; switch front/back; grid;
**live annotation + measurement**; geolocation on capture (permission); auto object-detect after capture.
2.**Supabase backend + storage** (#last, 🔑): Postgres data + Supabase Storage. **No localStorage reliance**
backend is the source of truth. Ask user for creds (URL, anon key, service-role key, bucket) with steps.
3. ⬜ Face clustering → People; OCR (tesseract.js) → document search; semantic "show me beach photos"
(transformers.js / Hugging Face CLIP) — all free, in-browser.
4. ⬜ Video editor + annotations; macOS-parity player polish.
## New requests (this session — must add)
-**Editor: straighten / tilt** (angle slider) — rotate by arbitrary degrees.
-**Crop: free-form AND fixed-ratio** — when a ratio (e.g. 1:1 square) is selected, dragging/stretching the
crop box keeps that aspect ratio; also free crop.
-**Import sync of existing metadata**: if an imported/mobile image already has tags (IPTC/XMP keywords) +
location + details, read and apply them (exifr supports XMP/IPTC). Keep object detection + object albums.
- 🚧 **Web-meaningful UI** (deep pass): "All Projects" → **"All Albums"** (this is a web gallery, not iOS);
remove "Open App Store" (meaningless on web); review sidebar tabs, **corner radius**, touch-ups so everything
is meaningful for a web gallery and matches macOS more closely.
-**Search dropdown defaults**: show quick filters **Recently Viewed**, **Recently Edited** (+ Recently Shared)
when the search field is focused.
-**Info → map click opens full Map** centered on that photo's location.
-**Map parity w/ macOS**: locations + images + tags; latest image shown for a tag; **click a tag → open
that tag's images grouped by date** (date-wise). Tag = album-like view, date-grouped.
-**Local-model fallback**: if Gemini key missing/invalid, use a local model. Concretely wire
`@imgly/background-removal` (free, in-browser) for Remove Background so it always works without a key.
-**Everything free**; build our own if no free option.
-**Memory in project `.claude/` folder** (this folder) — done.
- ⬜ More **advanced features** (open-ended; user will add more later).
## Notes
- getUserMedia needs HTTPS or localhost (dev OK). Permissions-Policy already allows camera/mic/geolocation=self.
- When Supabase is wired, switch the demo adapter from localStorage → Supabase and drop localStorage reliance.
+309
View File
@@ -0,0 +1,309 @@
# Build status & session log
## Run
`pnpm install && pnpm dev` → http://localhost:3000 (landing → Open Gallery → /gallery). `pnpm build` to ship.
## Current state (2026-06-29)
Phases 13 complete & verified (see [requirements.md](requirements.md)). `pnpm build` passes; /gallery ~170 KB
(TF.js + exifr lazy). Zero console errors. Default persistence = localStorage adapter (until Supabase wired).
## Session log
- S1: monorepo + SDK + demo; full macOS Photos UI; albums/smart-albums/recycle/import/editor/lightbox; light+dark;
responsive; nonce CSP; 19-finding review fixed.
- S2: free in-browser object detection (TF.js COCO-SSD) + Objects browser; Gemini generative editor (server route,
key server-side; 429 on free tier); fixed ORB video (local /sample-clip.mp4).
- S3: map popups; annotations incl. measurement double-arrow; EXIF; Info panel + mini-map; semi-dark + theming props;
docs/ + CLAUDE.md.
- S4: `.claude/memory/` created; web-meaningful UI (All Projects→**All Albums**, removed App Store; AlbumsOverview);
**custom camera (#1) DONE** (`components/Camera.tsx`: getUserMedia preview, photo capture, video record,
front/back switch, rule-of-thirds grid, live annotation+measurement, geolocation on capture, auto object-detect;
store `cameraOpen`/openCamera/closeCamera; toolbar 📷 button). Verified modal UI (no camera device in test env;
works with a real webcam). `pnpm build` passes (/gallery 172KB).
STILL OPEN: editor straighten/ratio-crop, search defaults (Recently Viewed/Edited), import IPTC/XMP tags,
local remove-bg fallback (@imgly), info-map→Map, tag→date-wise, face clustering, OCR, semantic search, video annotations.
- S5: **#2 Supabase backend DONE** — `apps/web/src/lib/adapters/supabaseAdapter.ts` (Postgres JSONB tables +
Storage blobs via putBlob); store gained `importFiles`/`uploadBlob` (uploads on import + camera capture);
TopToolbar/LibraryView/Camera route through them; GalleryClient uses Supabase when `NEXT_PUBLIC_SUPABASE_*` set
(else localStorage). CSP (middleware) now allows `https://*.supabase.co` (connect+img+media). Bucket `media` made
public. Creds in `apps/web/.env.local` (gitignored). Adapter degrades gracefully if tables missing (probe→warn→seed).
**USER MUST run `docs/supabase-setup.sql` once (SQL Editor) + reload** to activate persistence. `pnpm build` passes
(/gallery 234KB — supabase-js bundled; TODO lazy-load to trim). Verified app boots clean on Supabase adapter (seed
fallback) — only the expected gallery_media 404 probe until SQL is run.
NOTE: service_role key is in .env.local but the client adapter uses the anon key; service key only for admin/setup.
- S6: VERIFIED Supabase end-to-end (user ran SQL): app loads 45 rows from Postgres, 0 console errors; a camera
capture persisted + round-trips. FIXES: camera annotations moved to the REVIEW stage (annotate the captured still
→ saved as edits.annotations) + Undo/Clear; geolocation prefetched on camera open (10s, high-accuracy) + cached;
device info attached to capture exif (Make 'Web Camera', Model=track label, Resolution). Auto-albums: addMedia now
auto-creates+fills "Camera" (source camera) and "Screenshots" (source screenshot) user albums. Recycle: init purges
items trashed > 30 days (deletePermanently → syncs to Supabase); restore/permanent-delete already sync via save.
NEW FEATURES: editor **Straighten** slider (EditState.straighten + editTransformCss cover-scale); **info mini-map
click → focusMap → opens full Map centered** (store mapFocus); **tag chip click → setTagFocus → library filtered by
tag, date-grouped** (store tagFocus, libraryScale='days'); object/tag chips close overlays. VERIFIED tag→date-wise live.
`pnpm build` passes.
DEFERRED (next): interactive fixed-ratio/free crop (bake on save), search dropdown defaults (Recently Viewed/Edited),
import IPTC/XMP keywords, local remove-bg fallback (@imgly), face clustering/OCR/semantic search, video annotations,
lazy-load supabase-js (trim /gallery bundle), storage blob deletion on permanent-delete, reverse-geocode place names,
optional pg_cron for server-side 30-day purge.
- S7: **Interactive crop DONE & verified**`components/editor/CropBox.tsx` (drag/resize, 4 corner handles, masks,
thirds grid, aspect lock so square stays square), ratio presets (Free/Square/4:3/3:4/16:9/9:16/Original) in the
Crop tab; `lib/bake.ts` flattens crop+flip+filter+vignette+rotation/straighten to a JPEG on Done and re-maps
annotations; `PhotoEditor.save()` bakes when `hasGeometry(edits)` and uploads via `uploadBlob` (Supabase Storage).
FIXED Supabase storage 400: upsert/overwrite violated RLS → adapter now uploads to a UNIQUE path per call
(`${id}-${rand}.ext`, plain INSERT). VERIFIED: cropped a portrait screenshot to 16:9, baked + uploaded to bucket
(object `eMdEj_Ow32-…jpg`), displays cropped; 0 console errors; `pnpm build` passes.
STILL OPEN (next, in order): search dropdown defaults (Recently Viewed/Edited), import IPTC/XMP keywords,
local remove-bg fallback (@imgly), face clustering / OCR / semantic search, video annotations, lazy-load supabase-js.
- S8: **search dropdown defaults DONE** (store recentlyViewed[] + searchPreset 'viewed|edited|added'; openLightbox
records views; setSearchPreset; selectors handle presets; TopToolbar Recents dropdown on focus). FIX: 'edited'
preset keys off `editedAt||edits` (baked crops clear `edits` but set editedAt) — VERIFIED shows both crops + selfie.
**IPTC/XMP import DONE** (readExif parses iptc+xmp → Keywords/dc:subject → tags). **Lazy-load supabase-js DONE**
(GalleryClient dynamic-imports the adapter; /gallery 234KB→176KB). All typecheck + `pnpm build` pass; verified live.
STILL OPEN (next): local remove-bg fallback (@imgly; needs wasm-unsafe-eval CSP + connect-src for model host),
face clustering → People, OCR → documents, semantic search (transformers.js/HF), video annotations.
- S9: **Local Remove Background DONE & verified**`@imgly/background-removal` (lazy import) in
`createDemoAIProvider.generativeEdit`: op 'remove-background' runs the in-browser WASM model (no Gemini);
other ops still hit /api/ai/edit. CSP: added `'wasm-unsafe-eval'` to script-src + `blob: data:` and
`https://staticimgly.com` to connect-src (the ML worker fetches model/wasm from blob: URLs). VERIFIED:
removed background from the user's selfie capture → clean transparent PNG, 0 errors, saved+uploaded to Supabase.
`pnpm build` passes (/gallery 177KB; @imgly lazy). protobufjs build script declined in pnpm-workspace.yaml.
STILL OPEN (next, model-heavy): face clustering → People (face-api.js), OCR → Documents (tesseract.js),
semantic search (transformers.js/CLIP), video annotations. Each needs a model CDN host in connect-src.
- S10: **Face clustering → People DONE & verified**`apps/web/src/lib/ai/faceProvider.ts` (`@vladmandic/face-api`,
lazy import; tinyFaceDetector+faceLandmark68+faceRecognition; models from `cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model`,
added to connect-src). Wired as `detectFaces` in createDemoAIProvider. SDK: `lib/cluster.ts` (greedy euclidean
clustering, threshold 0.55, biggest-face-first); store `rebuildPeople()` (reuses existing person id/name by
media overlap so names survive re-clustering) + `personFocus`/`setPersonFocus`; selectors filter by personFocus;
AIAnalyzer now runs detectObjects+detectFaces (only what's missing per item) and calls rebuildPeople() after the
pass (and once on load if faces exist but People empty); PeopleView click → setPersonFocus (filters library +
shows count); LibraryView banner shows "Person: …". BUG FIXED: `hooks/useViewMedia.ts` omitted personFocus from
the state passed to mediaForView → filter was a no-op; added it (+dep). VERIFIED: face-api detected the selfie's
face (1 face, 128-D emb), People shows 1 person w/ circular cover + "1 photo", click → grid filtered to that 1
photo. Persisted to Supabase (gallery_people + media.personIds). `pnpm build` passes (/gallery 178KB; face-api lazy,
benign "Critical dependency: require" webpack warning only). Picsum seed photos / screenshots have no detectable
frontal faces → only the real selfie clusters, as expected.
STILL OPEN (next): OCR → Documents (tesseract.js), semantic search (transformers.js/CLIP), video annotations.
- S11: **OCR → Documents + searchable text DONE & verified** (planned via an `ultracode` Workflow that fanned out
5 parallel readers to map integration points, then an Opus synthesizer). `apps/web/src/lib/ai/ocrProvider.ts`
(NEW, tesseract.js@5.1.1, lazy import, single memoized worker; worker/core/lang paths ALL pinned to
cdn.jsdelivr.net so nothing falls back to the non-allow-listed tessdata.projectnaptha.com; `meaningfulText()`
confidence filter: per-word conf>=70 + >=4 words, else mean-conf>=72 + >=6 word-shaped tokens → '' for photos).
Wired as `ocr` in createDemoAIProvider. SDK: `types.ts` MediaItem.ocrText + SmartRule 'hasText' + ViewId
`sys:${string}`; `lib/text.ts` (NEW) sanitizeOcrText (control-char strip via RegExp-from-string, ws-collapse,
10k cap); `smartAlbums.ts` hasText eval + `sys:documents` album; `selectors.ts` searchMedia haystack += ocrText;
`AIAnalyzer.tsx` 3rd OCR leg (own `ocrText===undefined` gate, inner .catch so a slow/failed OCR can't abort
objects+faces, patches even '' so it isn't reprocessed); Sidebar + CollectionsView 'Documents' + 'document' icon.
CSP: cdn.jsdelivr.net added to script-src (connect-src already had it). CAUGHT VIA GROUND-TRUTH: first pass put
ALL 41 Picsum photos in Documents because tesseract hallucinates low-conf gibberish (meanConf 21-29) on
textureless photos → added the confidence filter; re-verified Picsum→'' , imported a text invoice → 213 chars of
accurate text, Documents shows exactly it, search "oceanfront villa" (words only inside the image) finds it.
Persisted to Supabase. `pnpm build` passes (/gallery 179KB; tesseract lazy). NOTE: ~189 benign console WARNINGS
("kernel … already registered for backend webgl") appear when face-api's bundled tfjs + coco-ssd's tfjs coexist
(triggered by analyzing a newly-imported image) — warnings only, 0 errors. A test invoice now lives in the demo
library as a live Documents example (uploaded to Supabase; user can delete).
ADVERSARIAL REVIEW (ultracode Workflow, 4 dims × find→refute, 15 agents): correctness + security CLEAN (0);
6 false alarms correctly refuted (worker leak, rebuildPeople N+1 writes, sequential model load, CSP-nonce-for-
blob-workers, etc.). FIXED 3 confirmed: (1) exported pure utils clusterFaces/FaceCluster/sanitizeOcrText/
OCR_TEXT_MAX_CHARS from index.ts (SDK-pattern consistency); (2) pinned tesseract.js EXACT 5.1.1 in package.json
(was ^5.1.1) so the hardcoded worker CDN version can't drift; (3) sanitizeOcrText truncates on a word boundary
at the 10k cap (was mid-word → search false-negative). DOCUMENTED (not fixed — minor/pre-existing, not regressions):
(a) OCR backfill gate `ocrText===undefined` won't re-run if a host disables then re-enables OCR (same pattern as
faces; fine while OCR stays on); (b) persist() debounce means a mid-analysis crash loses recomputable derived
metadata (pre-existing, affects all analysis; source media safe, re-derived next load).
STILL OPEN (next): semantic search (transformers.js/CLIP), video annotations.
- S12: **BIG new backlog from user (13 items)** — kicked off. WAVE 1 DONE & verified:
(1) Selection is now a TOGGLE — plain click selects, clicking the sole selected tile deselects
(PhotoTile.onClick); (2) the "..." More menu shows **Select All** then flips to **Unselect All** once all
current-view items are selected (TopToolbar.openMoreMenu; removed the stray clearSelection-on-open); (3) editor
now has **Save** (overwrite) + **Save as Copy** (uploads under a fresh id via new store action
`duplicateWithEdits`, then opens the album picker) + **Cancel** with an "unsaved changes" confirm (isDirty check;
Escape routes through it too via cancelRef); (4) **Copy to Album** (was "Add to Album") + **Move to Album**
(new `moveToAlbumPicker`, album-view only) + Remove from Album. VERIFIED in browser: toggle true→false,
Select All→46 selected→"Unselect All", editor bar = Cancel/Revert/Save as Copy/Save, context menu = Copy to Album.
`pnpm build` passes (/gallery 180KB).
PLANNING (ultracode background Workflow, 7 architects): file-level plans for the remaining big features saved at
tasks/wck4qn659.output (sharing+links, password-lock, map-pins, grid-mosaic, glass-design, semantic-search,
video-annotations) — use as the blueprint for the next waves.
REMAINING WAVES (in this order): People rename + pet detection; Objects browser verify/expand; Map thumbnail pins
+ click->location date-wise + screenshot pins; Map "Grid" mosaic (hero + verticals + auto-slide); Sharing/Shared
Albums/Activity + download links; Recently Deleted password lock + Settings; macOS glass/dark/radius design polish;
semantic search (CLIP); video annotations; then README + memory.
Design ref captured from user's macOS screenshots: glass dropdowns/sidebar (backdrop-blur + translucent dark
fill + hairline border + caret), thumbnail map pins w/ count badge, irregular mosaic Grid.
- S13: **WAVE 2 DONE & verified** — People rename + pet detection + Objects browser.
PETS: `constants.PET_LABELS` (dog/cat/bird/horse/rabbit); `store.rebuildPeople` now also builds pet groups from
objectLabels with STABLE ids `pet:<label>` (name preserved across rebuilds), appended to people[], denormalized
into media.personIds; AIAnalyzer triggers rebuild for objects too + on-load if pets present but ungrouped
(imports PET_LABELS). RENAME: `store.renamePerson(id,name)`; PeopleView tile restructured (div, not nested
buttons) — avatar button = view photos (personFocus), name button = rename via generalized `promptAlbumName`
(now takes opts.placeholder/confirmLabel; NamePrompt placeholder prop). OBJECTS: already in CollectionsView
(objectLabelCounts → setObjectFocus); verified. VERIFIED in browser: People & Pets shows human ("+ Add Name")
+ Birds + Horses; renamed human → "Davy" (persisted to gallery_people); Objects lists all 14 detected types,
click "boat" → 2 photos + "Object: boat" banner. `pnpm build` passes (/gallery 180KB).
NEXT: Wave 3 = Map thumbnail pins (count badge, click->location date-wise, screenshots w/ GPS pinned) + Map
"Grid" mosaic. Then sharing/activity+links, password-lock, glass design, semantic search, video annotations,
README. Blueprints for those in docs/feature-plans.json.
- S14: **WAVE 3 DONE & verified** — Map redesign.
PINS: `selectors.clusterByLocation(items, precision)` (round coords; precision rises with zoom) + `LocationCluster`.
MapView now renders Leaflet `divIcon` THUMBNAIL pins (`.apg-pin` img + count badge + tail; CSS in sdk.css) —
static html only, img.src + count set via DOM after mount (XSS-safe); re-clusters on `zoomend`; click pin →
local `cluster` state → bottom **sheet** (`.apg-map__sheet`) showing that location's photos via `groupByTime(…, 'day')`.
Uses refs (locatedRef/openClusterRef) so Leaflet's once-created callbacks read live state. Screenshots/any GPS
item pin (locatedMedia is source-agnostic). VERIFIED: 7 thumbnail pins w/ count badges; clicked one →
"New Delhi, India · 4 photos" sheet, date-grouped (Thu Jun 18 / Tue Mar 3).
GRID MOSAIC: new `components/views/MosaicGrid.tsx` (date sections via groupByTime 'month'; full-width
auto-sliding `SlideHero` (cross-fade through first 6, 3.5s interval, dots) + irregular `.apg-mosaic` CSS grid
with varied col/row spans from SPANS pattern; clicking opens lightbox). MapView 'grid' mode now renders it over
ALL live media. VERIFIED: 13 month sections, hero + varied tiles, not uniform. `pnpm build` passes (/gallery 181KB).
REMAINING (in order): sharing/shared-albums/activity + download links; Recently Deleted password lock + Settings;
macOS glass/dark/radius design polish; semantic search (CLIP); video annotations; README. (Plans: docs/feature-plans.json.)
- S15: **WAVE 4 DONE & verified** — Sharing + Password lock (both client-side, localStorage; no SQL migration).
LOCK: `lib/crypto.ts` (Web Crypto SHA-256 + app salt; hashPassword/verifyPassword); store `lock:{hash}` (persisted
to localStorage `apg:lock-hash`) + in-memory `lockUnlocked` + actions setLockPassword/removeLockPassword/unlockLock/
relock; RecentlyDeletedView shows a real password lock screen when hash && !unlocked; Sidebar Recently Deleted
trailing icon = lock/unlock dynamically; `openSecuritySettings` modal (set/change/remove) from ⋯ menu + trash header.
SHARING: types `ShareRecord`; store `shares[]` (localStorage `apg:shares`) + createShare(scope, ids, albumId)/
revokeShare; `openShareModal(ids, albumId?)` modal — share selected photo(s) OR tick one/more albums → generates a
`?shared=<token>` link, copies to clipboard, Download button; TopToolbar Share icon (when selection>0 or album view);
PhotoTile context "Share…" (gated on features.sharing); SharedAlbumsView (cards + copy-link + revoke) + ActivityView
(timeline) replace the old placeholders. VERIFIED: set pw→reload→lock screen→unlock OK; cleared test pw; shared 2
photos→link generated+stored, Shared Albums card + Activity "You shared '2 Photos' · just now". `pnpm build` passes
(/gallery 183KB). NOTE: lock/shares are device-local by design (not pushed to Supabase) — documented.
REMAINING: macOS glass/dark/radius design polish (needs visual work from the screenshots); semantic search (CLIP);
video annotations; README.
- S16: **WAVE 5 DONE & verified** — macOS glass/dark/radius polish (sdk.css). ROOT CAUSE found: `.apg-menu`,
`.apg-info`, `.apg-search__recents` used OPAQUE `--apg-bg-elevated`, so backdrop-filter had nothing to blur.
Added tokens `--apg-menu-bg` (light rgba(250,250,252,0.78) / dark rgba(44,44,46,0.7)), `--apg-glass-border`
(hairline), `--apg-radius-menu:12px`, `--apg-hairline:0.5px`; dark theme made darker (sidebar rgba(22,22,24,0.72)).
Applied glass to menu/sidebar(28px blur)/toolbar(24px)/info/search-recents with hairline borders + layered shadow +
inner top highlight; `@supports not (backdrop-filter)` → solid fallback. VERIFIED via getComputedStyle in BOTH
themes: light app #fff + menu rgba(250,250,252,0.78); dark app #1c1c1e + menu rgba(44,44,46,0.7); both blur(34px)
+ radius 12px; screenshot confirms translucent glass dropdown w/ checkmark. (Skipped the dropdown caret/notch —
menus open at arbitrary fixed coords so a triangle wouldn't align reliably; noted as future.) `pnpm build` 183KB.
NOTE: Playwright screenshot capture was returning stale frames this session (ERR_CACHE) — used computed styles as
ground truth. REMAINING: semantic search (CLIP); video annotations; README.
- S17: **WAVE 6 — Semantic search (CLIP) DONE & verified.** `apps/web/src/lib/ai/clipProvider.ts`
(@huggingface/transformers v3.8.1, `Xenova/clip-vit-base-patch16`, lazy; embedImage via canvas→RawImage→
CLIPVisionModelWithProjection; embedText via AutoTokenizer→CLIPTextModelWithProjection; q8/WASM). Wired
embedImage+embedText into createDemoAIProvider. AIAnalyzer: 4th `needsEmbedding` gate (m.embedding===undefined) +
4th Promise.all leg (own .catch). Store: `semanticResults: MediaId[]|null` + setSemanticResults; setSearch clears it.
NEW `components/SemanticSearch.tsx` (headless): debounced on searchQuery, embedText(query) → cosine-rank media w/
embeddings (threshold 0.22, top 60) → setSemanticResults; mounted in PhotoGallery. selectors.mediaForView blends
keyword hits then semantic-only matches and PRESERVES that relevance order (ranked flag skips chrono sort);
useViewMedia passes semanticResults. CSP: connect-src += huggingface.co + *.huggingface.co + *.hf.co (weights
redirect to HF Xet CDN us.aws.cdn.hf.co). VERIFIED: 44/45 images embedded (512-D, persisted); query "snow covered
mountain" (no keyword match) → top results are snowy peaks/mountains/fog — genuine CLIP ranking. build /gallery 184KB.
GOTCHA (same as faces/ocr): first pre-CSP-fix run set embedding=[] → gate wouldn't retry; cleared `embedding` in
Supabase, re-embed worked. NOTE: vision + text encoders download separately on first use (slow first time, cached).
REMAINING: **full video editor** (annotations + filters + trim + image overlay + mute + add music + Save/Save-as-Copy
— user expanded scope; plan = canvas + MediaRecorder + WebAudio, free, no ffmpeg/SharedArrayBuffer); README.
- S18: **WAVE 7 — Full video editor DONE & verified.** `lib/videoBake.ts` (canvas + MediaRecorder + WebAudio
export, NO ffmpeg/SharedArrayBuffer so CSP intact): offscreen muted video (muted=true for reliable autoplay;
audio tapped via WebAudio MediaElementSource), plays trimmed range, draws each frame with ctx.filter
(editFilterCss) + image overlay + rasterized annotations (clone live preview <svg> + add viewBox → data URL →
Image), captureStream(30) + mixed audio (original unless muted + optional music via gain) → MediaRecorder webm
(vp9/opus fallback chain); wall-clock deadline guards against a stalled loop. `components/editor/VideoEditor.tsx`
(tabs Adjust/Filters/Trim/Markup/Overlay/Audio; Save/Save-as-Copy(nanoid id + duplicateWithEdits + album picker)/
Cancel-with-confirm; progress %). EditState extended: trim{start,end}, overlay{src,x,y,scale}, audio{muted,
musicSrc,musicVolume}. PhotoEditor now skips videos (kind==='video'→null); VideoEditor mounted in PhotoGallery.
VERIFIED: opened a 30s clip → trim to 2s + Noir filter → Save as Copy → exported "MOV_5004 copy" kind=video
mime=video/webm;codecs=vp9,opus DURATION=2 uploaded to Supabase; album picker appeared. `pnpm build` 188KB.
===== ALL 13 BACKLOG ITEMS FROM THE BIG USER BATCH ARE DONE & VERIFIED. Only README refresh remains. =====
- S19: **2nd big user batch (~19 glitches/features).** Blueprints for the large ones in docs/overhaul-plans.json
(persistence root-cause, env-theming, upload, video player, map sheet, missing-features audit).
DONE & verified this turn (12): (1) Layout — MEASURED full-width in Library/Collections/Map (240 sidebar + content
= viewport); the "80/20 gap" couldn't be reproduced → likely DevTools docked/zoom on user's side. (2) Favorite —
clean heart path in icons/index.tsx + RED (.apg-tile__fav #ff3b30 + Lightbox heart red when fav); toggle was fine,
the white heart was just invisible. (3) Click a tile now OPENS the lightbox (PhotoTile.onClick; still additive in
selection mode); right-click menu adds Open + Select/Deselect and no longer auto-selects. (4) Sidebar = floating
rounded glass panel (border-radius var --apg-sidebar-radius default 14px + margin + full border). (5) Search focus
ring on .apg-search:focus-within (wraps icon+input). (6) SelectionBar split into Share (openShareModal) + Download
(download icon). (7) Lightbox: Edit now shows for VIDEO too, Download uses the download icon, added a Share button;
fav button red+aria-pressed. (8) All Albums collapsible (disclosure caret + albumsOpen). (9) Recently Deleted lock
icon clickable (Row onTrailingClick → toggleLock: no-pw→settings / unlocked→relock / locked→go unlock). (10) + and
Import open a new UploadModal (components/UploadModal.tsx): drag-drop + browse + multi + album picker default=current
album; importFiles(files, albumId?) now returns ids + files into the album; image/video only (mediaFromFile
validates). (11) Upload processing already auto (addMedia source-albums incl. Screenshots + AIAnalyzer). (12)
PERSISTENCE: store.init now treats the backend as the SINGLE SOURCE OF TRUTH (media=loaded.media; only seeds an
empty backend once) — fixes "some images missing/stale on refresh"; media never uses localStorage (only lock/shares
do, device-local by design). `pnpm build` passes (/gallery 188KB). All verified in-browser via getComputedStyle +
interaction probes.
STILL PENDING (large, next): map pins square-fit + hover tooltip + draggable resizable bottom-right sheet; custom
macOS video player; dedicated Crop menu w/ working rotate/tilt; ENV-driven customization (theme bg/gradient/opacity,
sidebar bg+radius, per-feature flags) + README + docs/ENV.md; smooth animations; advanced-features audit; deep test.
- S20: **Wave 2 of the 2nd batch DONE & verified (3 items).** (a) MAP pins: `.apg-pin__img` now
position:absolute inset:0 + width/height 100% !important (Leaflet's `.leaflet-container img` was leaving it at
natural size → non-square; VERIFIED now 43×43 square). Added a hover image tooltip (`.apg-pin__tip` + tip-img +
caption, set via DOM in MapView marker 'add'). Sheet is now bottom-RIGHT (width 460, right:12), height set inline
from `sheetH` state, with a drag GRIP (`.apg-map__grip` + pointer handlers onHandleDown/Move/Up) to resize toward
full (drag down past 0.2 dismisses). VERIFIED drag 474→758px. (b) CROP: removed the `tab==='crop'` exclusion on
transformCss in PhotoEditor so rotate/straighten/flip PREVIEW LIVE in the Crop tab (normal crop unaffected since
no-transform=no-op). VERIFIED straighten 12° → matrix rotate+cover-scale. (c) ENV CUSTOMIZATION: store `ThemeTokens`
(bg/elevated/sidebarBg/text light+dark, sidebarRadius, accent — any color/rgba/gradient) + GalleryConfig.themeTokens;
PhotoGallery maps tokens→CSS vars per resolvedTheme into rootStyle; exported ThemeTokens. Demo
`apps/web/src/lib/galleryConfig.ts` reads NEXT_PUBLIC_APG_* (feature flags + theme tokens + accent/radius/theme);
GalleryClient applies env over defaults. Docs: `docs/ENV.md` (full var list + examples) + README Customization
section + .env.example. `pnpm build` passes (/gallery 189KB). 15/19 of the 2nd batch done.
STILL PENDING: custom macOS video player; smooth animations; advanced-features audit; deep test.
- S21: **Wave 3 (final) of the 2nd batch DONE & verified — ALL 19 items complete.** (a) CUSTOM VIDEO PLAYER:
`components/VideoPlayer.tsx` (glass transport: play/pause, pointer-drag scrubber w/ buffered+played, time,
mute+volume, PiP, fullscreen, auto-hide, keyboard) replaces native controls in Lightbox; new icons pause/volume/
mute/expand/pip. VERIFIED custom controls + no native + play/pause. (b) ANIMATIONS: tile hover zoom (hover:hover),
`.apg-scroll` view fade-in keyframe, prefers-reduced-motion guard. (c) FEATURE AUDIT documented in ROADMAP §7b
(prioritized backlog). (d) DEEP TEST = ultracode adversarial review workflow (3 dims × find→refute, 17 agents);
correctness/security mostly clean, refuted false alarms (place-name XSS, video edit routing, sheet stale-closure).
FIXED all confirmed: CRITICAL VideoPlayer arrows double-fired the Lightbox window nav → owned keys now
e.nativeEvent.stopImmediatePropagation() (VERIFIED: ArrowRight seeks 1→2.9s, lightbox does NOT navigate); HIGH
pointercancel leaks on VideoPlayer scrubber + MapView grip → added pointercancel cleanup; HIGH init() double-seed
under Strict Mode/remount → `initedRef` guard runs init once; MED scrubber seek when duration=0 → guarded;
MED selection-mode click-can't-open → mitigated by existing double-click/Enter/right-click Open; LOW UploadModal
now uses isAcceptedMediaFile (matches backend allow-list); LOW accent env redundancy removed; LOW themeToken CSS
values sanitized (reject url()/expression()/breakout). `pnpm build` passes (/gallery 191KB).
===== 2ND BATCH (19 items: layout, favorite, select/open, sidebar radius, search focus, bottom bar, lightbox
buttons, collapsible albums, lock icon, upload, persistence, map pins+sheet, crop tilt, env customization + ENV.md,
video player, animations, features audit, deep test) ALL DONE & VERIFIED. Remaining known limitations: sharing/lock
are device-local; advanced backlog in ROADMAP §7b (zoom/pan, metadata editing, slideshow, etc.) for future waves. =====
- S-versions (2026-07-02): **Versioning + Audit Log + Comments DONE & verified** (photos AND videos).
DATA: `types.ts``MediaItem.versions?: MediaVersion[]` (v1 = original, never overwritten; each save appends)
+ `MediaItem.comments?: MediaComment[]`; new `MediaVersion`/`MediaComment` interfaces; `ViewId` += `'versions'`.
STORE (`store/store.ts`): `addVersion(id, patch, changes)` (seeds v1 from the pre-edit snapshot on first edit,
then appends), `restoreVersion(id, versionId)` (sets live fields to the chosen version + appends a "Restored
version N" audit entry), `addComment(id, text, author?)`, `deleteComment(id, commentId)` — all persist().
AUDIT LOG helper: `lib/versions.ts` `summarizeEdits(edits): string[]` (Cropped / Rotated / Filter: x / N
annotations / Trimmed / Muted / etc.). EDITOR WIRING: `PhotoEditor.save(false)` & `VideoEditor.save(false)`
now call `addVersion(...)` instead of `updateMedia` (Save-as-Copy path unchanged). UI: `InfoPanel.tsx` gained a
**Version history** section (thumbnail per version w/ live CSS filter+transform preview, timestamp, expandable
"What changed" audit list, Restore button on non-current) + **Comments** section (avatar/author/time list, delete
on hover, name+textarea+Post form; author cached in localStorage `apg:comment-author`; Cmd/Ctrl+Enter posts).
SIDEBAR: Utilities → "Versions & Audit" (icon clock, view `versions`). NEW VIEW: `views/VersionsView.tsx`
(audit browser — every item with versions or comments; click → openLightbox+Info). ViewRouter case `versions`.
CSS: `styles/sdk.css` apg-info__section / apg-versions / apg-version__* / apg-comments / apg-comment* /
apg-comment-form* / apg-audit-* + `.apg-btn--small`/`.apg-btn:disabled`. PERSISTENCE: stored inline on MediaItem →
Supabase JSONB (`data: m`) automatically; no schema change needed. VERIFIED: (a) unit test vs built dist —
original preserved as v1, v2/v3 appended with audit logs, live src = latest, restore works, comment add/delete +
author OK; (b) live UI on :3007 — sidebar item present, empty state, Info shows both sections, posted a comment
(Davy → rendered w/ time), audit browser lists the item ("test-invoice.png · 1 version · 1 comment"). typecheck +
`pnpm build` pass. NOTE: dev :3000 was occupied by a stale 500 server; tested on :3007.
- S-advanced (2026-07-07): **Advanced video editor + object smart albums + upload/analysis/versioning/persistence fixes + deploy guide.** Diagnosed via a 6-agent workflow (root causes in that run's journal).
VERSIONING BUGS FIXED: (1) reload-strip — `lib/media.ts createMediaItem` now copies `versions`+`comments`
(the localStorage load path dropped them → "stuck at v1" after refresh). (2) Save-as-Copy — `store.duplicateWithEdits(sourceId,patch,changes?)` now seeds the copy's OWN [v1 Original, v2 edit] history + resets comments (was inheriting/blank). Editors hoist `changes` + pass to duplicateWithEdits; catch fallbacks now addVersion/duplicateWithEdits (not applyEdit) so history is never lost.
PERSISTENCE: `docs/supabase-setup.sql` now CREATES the public `media` bucket (was never created → uploads silently fell back to non-durable URLs). `store.importFiles` + `Camera` never persist a bare `blob:` URL — convert to a durable data URL (`blobToDataUrl` now exported from lib/media).
UPLOAD: `UploadModal` has an explicit "Choose files" button + visually-hidden input (not `hidden` attr) + resets value so re-picking same file fires onChange.
ANALYSIS-ON-UPLOAD: `AIAnalyzer` now collects pending NEWEST-FIRST + drains in rounds (items imported mid-run are caught) → uploads analyze like captures; also calls `syncObjectAlbums()` on completion.
OBJECT SMART ALBUMS: `lib/smartAlbums.ts objectSmartAlbums(media,now,minCount)` (one live `sys:obj:<label>` smart album per detected label, rule {field:'object',op:'contains'}); `store.syncObjectAlbums()` (interface + impl near rebuildPeople; called at end of init()); Sidebar "Objects" collapsible section (`sys:obj:*`); CollectionsView Objects cards now `setView('sys:obj:'+label)`. Membership live via resolveSmartAlbum; albums not persisted (regenerated).
VIDEO EDITOR (heavy): types `EditState.segments/overlays/posterTime/export` + extended `audio` (originalVolume/fadeIn/fadeOut); new `VideoSegment/VideoOverlay/OverlayKeyframe`. New `lib/videoTimeline.ts` (normalizeSegments/outputDuration/sourceToOutputTime/videoOutputSize/resolveOverlays/sampleOverlay — pure, shared by bake+preview, exported from index). `lib/videoBake.ts` REWRITTEN as a timeline engine: ONE continuous MediaRecorder driven across segments (multi-trim + per-seg speed), crop+90°rot+flip geometry, CSS filter, overlays (image+text) keyframe-interpolated at output time, watermark, legacy overlay/annotations, WebAudio (original vol/mute + music + master fade), poster grab, export quality. `VideoEditor.tsx` REBUILT with tabs Trim&Split / Crop&Rotate / Overlays&Text / Filters / Adjust / Markup / Audio / Export + live keyframed overlay preview + surfaced export errors. `summarizeEdits` extended.
DEPLOY: `docs/DEPLOY.md` (Vercel Root Dir=apps/web + Supabase free tier, env vars, gotchas: bucket public, CSP dynamic, Vercel 4.5MB body cap for Gemini). VERIFIED: `pnpm typecheck` + `pnpm build` pass (/gallery 198KB); 17/17 logic unit tests vs built dist (versioning increment/copy-history, objectSmartAlbums, timeline+keyframe interpolation, summarizeEdits); live browser (Edge) — 0 console errors, Objects sidebar live (Person/Car/Chair…), upload modal Choose-files, video editor all 8 tabs + text overlay live preview + keyframe UI. Did NOT git commit (user commits).
- S-deploy-ai (2026-07-07): **Manual (no-Git) deploy guide + swappable free/local AI backend.**
DEPLOY: `docs/DEPLOY-MANUAL.md` — 3 no-Git paths: (A) Vercel CLI `vercel --prod` from repo root, Root Dir=apps/web (recommended; keeps SSR+nonce CSP+AI route); (B) self-host Node (`pnpm build``pnpm --filter web start` on Render/Railway/VPS); (C) static export to any host — requires `output:'export'` + DISABLING the nonce middleware/layout headers (security trade-off, no server AI route). SDK ships as source via transpilePackages so "whole project" = build the demo (no npm publish).
AI: `apps/web/src/app/api/ai/edit/route.ts` REFACTORED to a pluggable backend via `AI_EDIT_PROVIDER` (auto|local|huggingface|gemini|none). auto = local→huggingface→gemini. `local` = own Stable Diffusion (AUTOMATIC1111/Forge/SD.Next `/sdapi/v1/img2img`, env LOCAL_SD_URL+denoise/steps/sampler; free+private). `huggingface` = free Inference API instruct-pix2pix (HF_API_TOKEN, HF_IMAGE_MODEL; 503 cold-start handled). `gemini` kept (needs BILLED key for image). MAX_BASE64 lowered to 4M (under Vercel 4.5MB). Server-side fetches → no CSP change. KEY POINT documented in `docs/AI-SETUP.md`: analysis (objects/faces/OCR/embeddings via TF.js/face-api/tesseract/transformers) + Remove Background (@imgly) already run FREE in-browser, no key — only generative pixel edits needed a backend. `.env.example` rewritten with Supabase + all AI options. typecheck + build pass. Did NOT git commit.
## Key files
- 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).
- SDK: `packages/photo-sdk/src/{components,store,adapters,ai,lib,icons,styles}`.
- Demo: `apps/web/src/{app,components,lib}` (app/api/ai/edit = Gemini proxy; middleware = nonce CSP).
- Camera (S4): `packages/photo-sdk/src/components/Camera.tsx` (store flag `cameraOpen`).
## Do NOT
- git commit/push. Commit secrets. Rely on localStorage as the final backend (Supabase is the target).
+59
View File
@@ -0,0 +1,59 @@
# Copy to .env.local (never commit real secrets — .env* is gitignored).
# ---------------------------------------------------------------------------
# Persistence (Supabase) — see docs/DEPLOY.md. Without these the app runs on
# browser-local storage only. Client-safe (anon key is gated by RLS).
# ---------------------------------------------------------------------------
# NEXT_PUBLIC_SUPABASE_URL=https://YOUR-PROJECT.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key
# NEXT_PUBLIC_SUPABASE_BUCKET=media
# ---------------------------------------------------------------------------
# Generative image editing backend (Restore / Colorize / Replace-Sky / Prompt).
# NOT required to run the app: object detection, faces, OCR, and Remove Background
# all run FREE in-browser with no key. Full guide: docs/AI-SETUP.md
# ---------------------------------------------------------------------------
# AI_EDIT_PROVIDER=auto # auto | local | huggingface | gemini | none
#
# Option 1 — your own local Stable Diffusion (AUTOMATIC1111/Forge/SD.Next; free + private):
# LOCAL_SD_URL=http://127.0.0.1:7860
# LOCAL_SD_DENOISE=0.55
# LOCAL_SD_STEPS=25
# LOCAL_SD_SAMPLER=Euler a
#
# Option 2 — free Hugging Face Inference API (free token, no GPU):
# HF_API_TOKEN=hf_xxxxxxxx
# HF_IMAGE_MODEL=timbrooks/instruct-pix2pix
#
# Option 3 — Google Gemini (image output needs a BILLED key; free tier is text-only):
# GEMINI_API_KEY=
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
# Optional: override the demo seed image source.
# NEXT_PUBLIC_SEED_IMAGE_HOST=https://picsum.photos
# ---------------------------------------------------------------------------
# UI customization (all optional; unset = SDK defaults). Full guide: docs/ENV.md
# ---------------------------------------------------------------------------
# Feature flags (true/false) — hide any capability:
# NEXT_PUBLIC_APG_EDITOR=true
# NEXT_PUBLIC_APG_CAMERA=true
# NEXT_PUBLIC_APG_AI=true
# NEXT_PUBLIC_APG_MAP=true
# NEXT_PUBLIC_APG_IMPORT=true
# NEXT_PUBLIC_APG_EXPORT=true
# NEXT_PUBLIC_APG_SHARING=true
#
# Theme & appearance:
# NEXT_PUBLIC_APG_THEME=system # system | light | dark | semi-dark
# NEXT_PUBLIC_APG_ACCENT=#0a84ff
# NEXT_PUBLIC_APG_RADIUS=10 # base corner radius (px)
# NEXT_PUBLIC_APG_SIDEBAR_RADIUS=14 # sidebar panel radius (px)
# NEXT_PUBLIC_APG_BG_DARK=linear-gradient(165deg,#141018,#241426) # color OR gradient
# NEXT_PUBLIC_APG_BG_LIGHT=#ffffff
# NEXT_PUBLIC_APG_SIDEBAR_BG_DARK=rgba(20,20,22,0.6) # rgba() for opacity
# NEXT_PUBLIC_APG_SIDEBAR_BG_LIGHT=rgba(246,246,248,0.72)
# NEXT_PUBLIC_APG_ELEVATED_DARK=#2c2c2e
# NEXT_PUBLIC_APG_ELEVATED_LIGHT=#f5f5f7
# NEXT_PUBLIC_APG_TEXT_DARK=#f5f5f7
# NEXT_PUBLIC_APG_TEXT_LIGHT=#1d1d1f
+40
View File
@@ -0,0 +1,40 @@
# dependencies
node_modules/
.pnp
.pnp.js
# builds
dist/
build/
.next/
out/
*.tsbuildinfo
# env / secrets (NEVER commit secrets)
.env
.env.*
!.env.example
# logs
*.log
npm-debug.log*
pnpm-debug.log*
# os / editor
.DS_Store
Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
# test artifacts
playwright-report/
test-results/
coverage/
.playwright-mcp/
*.jpeg
*.png
!apps/web/**/*.png
!packages/**/*.png
.vercel
+3
View File
@@ -0,0 +1,3 @@
auto-install-peers=true
strict-peer-dependencies=false
shamefully-hoist=false
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
+54
View File
@@ -0,0 +1,54 @@
# CLAUDE.md — project memory for AI assistants & developers
> **This file is the project-local source of truth.** It lives in the repo (not in any individual's
> `~/.claude`), so any developer — or any Claude/AI account that opens this package — can read the
> full context, decisions, and roadmap. Keep it updated as the project evolves.
## What this is
A **reusable macOS Photosstyle photo gallery SDK** (`@photo-gallery/sdk`) for React / Next.js, plus a
Next.js demo (`apps/web`). Goal: a near carbon-copy of macOS Photos (light/dark/semi-dark, fully
responsive) where **every feature is optional via flags**, so each consuming project enables only what
it needs. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the complete, living feature checklist
(done / in-progress / planned) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the design.
## Monorepo
- `packages/photo-sdk` — the SDK (React components, Zustand store, adapters, AI provider interface,
lib utils, self-contained CSS). **No heavy/ML deps in here** — they're injected by the host app.
- `apps/web` — Next.js demo: landing page (`/`) + gallery (`/gallery`), AI providers, server routes.
## Conventions
- SDK ships its own `styles.css` (no Tailwind required by consumers). Classes are prefixed `apg-`.
- State: a per-instance Zustand store (`createGalleryStore`) provided via React context.
- Storage is a pluggable `StorageAdapter` (default: localStorage/IndexedDB). AI is a pluggable
`AIProvider` (object detection / faces / OCR / caption / embeddings / generativeEdit) — all optional.
- Every capability is gated by a feature flag (`features={{ editor, camera, ai, map, import, export, ... }}`).
- 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.
## Secrets
- API keys go in `apps/web/.env.local` (gitignored via `.env.*`). **Never commit keys.** Gemini is
read server-side only in `apps/web/src/app/api/ai/edit/route.ts`. See `.env.example`.
## Run
```bash
pnpm install && pnpm dev # http://localhost:3000 (landing → Open Gallery → /gallery)
pnpm build # SDK (tsup) + Next production build
pnpm typecheck
```
Full setup/troubleshooting: [`docs/SETUP.md`](docs/SETUP.md).
## Memory & status
**AI/working memory lives in [`.claude/memory/`](.claude/memory/)** (committed with the repo, not in any
machine's global `~/.claude`):
- [`.claude/memory/status.md`](.claude/memory/status.md) — current status + session log.
- [`.claude/memory/requirements.md`](.claude/memory/requirements.md) — the complete requirements backlog.
Human-facing feature checklist is [`docs/ROADMAP.md`](docs/ROADMAP.md). Read `.claude/memory/` + `docs/ROADMAP.md`
first — they are authoritative and travel with the code.
+263
View File
@@ -0,0 +1,263 @@
# Photo Gallery Web SDK
A **reusable, macOS Photosstyle photo gallery** for React / Next.js — packaged as an
installable SDK (`@photo-gallery/sdk`). Think of it as the *shadcn of photo galleries*: drop in
one component and get a nearpixel recreation of the macOS Photos app, in light **and** dark mode,
fully responsive from ultrawide desktops down to a Galaxy Fold.
> Original implementation. Not affiliated with, or endorsed by, Apple. All icons and assets are
> drawn from scratch.
```tsx
import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
export default function App() {
return <PhotoGallery photos={myPhotos} theme="system" features={{ editor: true, map: true }} />;
}
```
---
## ✨ What's implemented
Every capability below is **optional via `features={{…}}` flags**, so a consuming app enables only
what it needs. The full, living checklist is in [`docs/ROADMAP.md`](docs/ROADMAP.md).
### UI, library & albums
| Area | Details |
| --- | --- |
| **Faithful macOS UI** | Sidebar, Library (Years / Months / All), Collections, Map, People & Pets, Documents, Recently Deleted (locked), Shared Albums, Activity — **light / dark / semidark**, with **macOS "glass" vibrancy** (backdropblur menus + sidebar) |
| **Library** | Adaptive responsive grid, zoom, lazy loading, day/month/year grouping; **click toggles select**, **Select All ↔ Unselect All** |
| **Albums** | Create · rename · duplicate · delete · nested folders · covers · **Copy to / Move to Album** |
| **Smart albums** | Screenshots / Videos / Favourites / Recently Saved / RAW / Live / Panoramas / **Documents** (OCR), recomputed live |
| **Map** | Free OSM / Esri tiles (no key). **macOS thumbnail pins** with count badges + clustering; tap a pin → that location's photos dategrouped. **Grid** tab = Memoriesstyle mosaic (autosliding hero + irregular tiles) |
| **Import / capture** | Draganddrop & filepicker (validated types); **custom camera** (photo/video, front/back, grid, live annotation + measurement, geolocation, auto objectdetect) |
| **Sharing** | Share a photo / selection / oneormore albums → copyable link + download; **Shared Albums** + **Activity** feed |
| **Recycle bin** | 30day trash; **password lock** (Web Crypto, devicelocal) with lock/unlock states + Settings |
### AI — all **free & inbrowser** (no API key), lazyloaded, pluggable
| Capability | Engine |
| --- | --- |
| **Object detection** → tags, search, Objects browser | TensorFlow.js COCOSSD |
| **Face clustering** → People (rename); **pet detection** → Pets | faceapi.js (128D descriptors) |
| **OCR** → searchable text + Documents album | tesseract.js (confidencefiltered) |
| **Semantic search** — "show me snowy mountains" | CLIP via transformers.js |
| **Remove Background** | @imgly (inbrowser WASM) |
| **Generative edits** (restore / colorize / sky / prompt) 🔑 | Gemini (serverproxied; needs a key) |
### Editors
| Area | Details |
| --- | --- |
| **Photo editor** | Adjust (13 sliders), 10 filters, **crop** (free + fixedratio) & straighten, **markup** (rect/oval/line/arrow/doublearrow measurement/text/freehand), AI tab — **Save** or **Save as Copy**, Cancelconfirm |
| **Video editor** | Adjust · Filters · **Trim** · **Markup** · **Overlay** (composite an image) · **Audio** (mute + add music) — exports a new clip inbrowser (canvas + MediaRecorder + WebAudio, no ffmpeg); **Save / Save as Copy** |
### Platform
| Area | Details |
| --- | --- |
| **Backend** | Pluggable `StorageAdapter` — zeroconfig localStorage/IndexedDB default, or **Supabase** (Postgres + Storage) adapter included |
| **Search** | Name / place / tag / object / **OCR text** / **semantic** — blended and relevanceranked |
| **Theming** | `accentColor` + `borderRadius` props; Light / Dark / SemiDark / System |
| **A11y / responsive** | Keyboard nav, focus traps, reducedmotion, ARIA; works down to ~280 px |
| **Security** | Perrequest **nonce CSP**, security headers, filename sanitisation, URLscheme allowlist, serveronly API keys |
---
## 🗂 Monorepo layout
```
advance-photo-gallery-web-sdk/
├── packages/
│ └── photo-sdk/ # @photo-gallery/sdk — the reusable product
│ ├── src/
│ │ ├── components/ # PhotoGallery, Sidebar, views, editor, lightbox …
│ │ ├── store/ # Zustand store + selectors + React context
│ │ ├── adapters/ # StorageAdapter interface + localStorage default
│ │ ├── ai/ # AIProvider interface (pluggable)
│ │ ├── lib/ # classify, smart albums, grouping, edits, media, format
│ │ ├── icons/ # original SFstyle SVG icon set
│ │ └── styles/ # sdk.css (selfcontained, no Tailwind needed)
│ └── dist/ # built ESM + CJS + d.ts + styles.css (after `build:sdk`)
└── apps/
└── web/ # Next.js demo: landing page + /gallery
```
---
## 🚀 Run it locally
**Prerequisites:** Node ≥ 20 and [pnpm](https://pnpm.io) (`corepack enable` ships it with Node).
```bash
# 1. install
pnpm install
# 2. start the demo (Next.js)
pnpm dev
# 3. open
# http://localhost:3000 → landing page
# http://localhost:3000/gallery → the full gallery (40+ sample photos)
```
The demo runs with **no backend and no API keys** — sample photos come from the free Picsum
service and the map uses free OpenStreetMap/Esri tiles.
### Other scripts
```bash
pnpm build:sdk # build the publishable SDK (dist/: ESM, CJS, types, styles.css)
pnpm build # build SDK + production Next.js demo
pnpm start # serve the production demo
pnpm typecheck # type-check every package
pnpm format # prettier
```
---
## 📦 Using the SDK in your own app
```bash
pnpm add @photo-gallery/sdk # (after publishing, or via workspace link)
```
```tsx
'use client';
import { PhotoGallery, type MediaItem } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
const photos = [
{ src: '/img/beach.jpg', name: 'Beach.jpg', takenAt: Date.now(), tags: ['beach'] },
];
export default function Gallery() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<PhotoGallery photos={photos} theme="system" />
</div>
);
}
```
> **Next.js note:** mount the gallery in a Client Component (it uses browser APIs). The demo gates
> it behind a mount check in [`apps/web/src/components/GalleryClient.tsx`](apps/web/src/components/GalleryClient.tsx).
> Import `leaflet/dist/leaflet.css` once (e.g. in your root layout) if you use the Map view.
### Key props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `photos` | `MediaItem[] \| { src, … }[]` | `[]` | Initial library; loose inputs are normalised + autoclassified |
| `albums` | `Album[]` | `[]` | Initial user albums |
| `adapter` | `StorageAdapter` | localStorage | Persistence backend (swap for S3/DB/REST) |
| `ai` | `AIProvider \| boolean` | `false` | Object/face/caption/search/generative provider |
| `theme` | `'system' \| 'light' \| 'dark'` | `'system'` | Appearance |
| `accentColor` | `string` | `#0a84ff` | Accent colour |
| `borderRadius` | `number` | `10` | Base corner radius (px) for all rounded UI |
| `themeTokens` | `ThemeTokens` | | Pertheme background / gradient / opacity / sidebar colour + radius overrides |
| `features` | `Partial<GalleryFeatures>` | all on | Toggle editor / camera / ai / map / import / export / sharing |
| `showWindowChrome` | `boolean` | `false` | Render the macOS trafficlight title bar |
### 🎨 Customization (props **or** env)
Every colour, gradient, opacity, sidebar radius, and feature flag is configurable. Pass
`themeTokens` / `features` as props, or — in the demo — set `NEXT_PUBLIC_APG_*` env vars and the
gallery reconfigures with **no code changes**. Unset values fall back to the builtin
light / dark / semidark themes. **Full list + examples: [`docs/ENV.md`](docs/ENV.md).**
```tsx
<PhotoGallery
theme="dark"
accentColor="#ff375f"
themeTokens={{ bgDark: 'linear-gradient(165deg,#141018,#241426)', sidebarBgDark: 'rgba(30,20,38,0.6)', sidebarRadius: 18 }}
features={{ camera: false, sharing: false }}
/>
```
### Custom storage backend
```ts
import type { StorageAdapter } from '@photo-gallery/sdk';
const myApiAdapter: StorageAdapter = {
name: 'rest',
async load() { return (await fetch('/api/library')).json(); },
async save(state) { await fetch('/api/library', { method: 'PUT', body: JSON.stringify(state) }); },
};
<PhotoGallery adapter={myApiAdapter} />;
```
### Custom AI provider
Every method is optional and the UI degrades gracefully. Implement object detection clientside
(TensorFlow.js), or proxy to a cloud model:
```ts
import type { AIProvider } from '@photo-gallery/sdk';
const provider: AIProvider = {
name: 'coco-ssd',
async detectObjects(item, image) { /* return DetectedObject[] */ return []; },
};
<PhotoGallery ai={provider} />;
```
---
## 🔒 Security
- **Strict ContentSecurityPolicy** + `XFrameOptions: DENY`, `nosniff`, `ReferrerPolicy`,
`PermissionsPolicy` (see [`apps/web/next.config.mjs`](apps/web/next.config.mjs)).
- **No `eval`, no `dangerouslySetInnerHTML`** anywhere in the SDK.
- **Import allowlist** — only image/video MIME types and extensions are accepted.
- **Filename sanitisation** on export (pathtraversal guard).
- **Defensive persistence** — persisted state is shapevalidated before use.
---
## 🛣 Roadmap (Phase 2+)
**Live now** (all free & inbrowser unless marked 🔑):
- **Object detection** (TF.js COCOSSD) → tags, search, Objects browser.
- **Face clustering → People** (rename) + **pet detection → Pets** (faceapi.js).
- **OCR → searchable text + Documents album** (tesseract.js, confidencefiltered).
- **Semantic search** — naturallanguage "looks like" queries (CLIP via transformers.js).
- **Remove Background** (@imgly, inbrowser WASM).
- **Supabase backend** adapter (Postgres + Storage), alongside the zeroconfig localStorage default.
- **Full video editor** (trim / filters / markup / image overlay / mute + music) exporting a new clip inbrowser.
- **Sharing** (links + Shared Albums + Activity), **passwordlocked Recently Deleted**, **custom camera**.
- **Generative AI editor** 🔑 (Gemini) — the editor's **AI tab** (Restore, Colorize, Replace Sky, freeform
prompts). The key stays **serverside only**: the browser calls the app's own
[`/api/ai/edit`](apps/web/src/app/api/ai/edit/route.ts) route, which proxies to Gemini.
### Enabling the generative editor
1. Put your key in `apps/web/.env.local` (gitignored — never committed):
```
GEMINI_API_KEY=your-key
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
```
2. Restart `pnpm dev`.
> **Note:** Gemini **image generation requires a billingenabled / paid plan**. A freetier key authenticates but
> returns HTTP 429 ("check your plan and billing") for image output. The editor handles this gracefully (shows the
> error, no crash); it produces images as soon as the key's project has imagegeneration quota.
Still planned / not yet implemented:
- **Auth & multiuser sharing** (real recipients, permissions). Sharing today creates devicelocal links.
- **Captions** (transformers.js BLIP) and a firstclass Hugging Face Inference provider option.
- **Map** heatmaps / trips / reversegeocode place names; true grid **virtualization** for millions of items.
- **Serverside** 30day purge (pg_cron), Storage blob deletion on permanentdelete.
- Storybook + API docs, Docker/K8s, automated CI.
---
## 🧱 Tech
Next.js · React · TypeScript · Zustand · Framer Motion · Leaflet · TanStack Virtual · pnpm
workspaces · tsup. The SDK ships its **own selfcontained stylesheet** — Tailwind is not required by
consumers.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
// Static security headers. The Content-Security-Policy is set per-request in
// middleware.ts so it can carry a unique nonce (no 'unsafe-inline' scripts).
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(self), geolocation=(self)' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
];
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@photo-gallery/sdk'],
async headers() {
return [{ source: '/:path*', headers: securityHeaders }];
},
};
export default nextConfig;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"@imgly/background-removal": "^1.7.0",
"@photo-gallery/sdk": "workspace:*",
"@supabase/supabase-js": "^2.108.2",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"leaflet": "^1.9.4",
"next": "^15.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tesseract.js": "5.1.1"
},
"devDependencies": {
"@types/node": "^20.16.11",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
+241
View File
@@ -0,0 +1,241 @@
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 60; // SD / cold-start models can take a while
/**
* Generative image-edit proxy. The BACKEND is pluggable so you are not tied to a
* paid model — pick one with env `AI_EDIT_PROVIDER` (default `auto`):
*
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
* SD.Next img2img API). Env: LOCAL_SD_URL (e.g. http://127.0.0.1:7860).
* 100% free + private, needs a GPU box you run. Most reliable.
* - `huggingface` → free Hugging Face Inference API (instruction image editing).
* Env: HF_API_TOKEN (free), HF_IMAGE_MODEL (default instruct-pix2pix).
* - `gemini` → Google Gemini image model (needs a billed key for image output).
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
* - `auto` → first configured of: local → huggingface → gemini.
*
* NOTE: `remove-background` never reaches here — it runs fully in-browser (@imgly,
* free, no key). Object detection / faces / OCR / embeddings are also 100% in-browser.
* See docs/AI-SETUP.md.
*/
const OP_PROMPTS: Record<string, string> = {
restore:
'Restore and enhance this photograph: improve sharpness and clarity, correct exposure and white balance, reduce noise and compression artifacts, recover detail. Keep it natural and photorealistic.',
colorize: 'Colorize this image with natural, realistic, well-balanced colors.',
'replace-sky':
'Replace the sky with a dramatic, beautiful golden-hour sky with soft clouds. Keep the foreground subject unchanged and the result photorealistic.',
};
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — stays under serverless body limits (e.g. Vercel ~4.5MB)
type Provider = 'local' | 'huggingface' | 'gemini' | 'none';
function resolveProvider(): Provider {
const explicit = (process.env.AI_EDIT_PROVIDER || 'auto').toLowerCase();
if (explicit === 'local' || explicit === 'huggingface' || explicit === 'gemini') return explicit;
if (explicit === 'none') return 'none';
// auto: prefer a private local server, then free HF, then Gemini.
if (process.env.LOCAL_SD_URL) return 'local';
if (process.env.HF_API_TOKEN) return 'huggingface';
if (process.env.GEMINI_API_KEY) return 'gemini';
return 'none';
}
interface EditResult {
imageBase64: string;
mimeType: string;
}
export async function POST(req: NextRequest) {
const provider = resolveProvider();
if (provider === 'none') {
return NextResponse.json(
{
error:
'AI image editing is not configured. Set LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (free Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key. See docs/AI-SETUP.md.',
},
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 });
}
const { imageBase64, mimeType, op } = (body ?? {}) as {
imageBase64?: unknown;
mimeType?: unknown;
op?: { type?: string; prompt?: string };
};
if (typeof imageBase64 !== 'string' || imageBase64.length === 0 || imageBase64.length > MAX_BASE64) {
return NextResponse.json({ error: 'Invalid or oversized image (try a smaller image).' }, { status: 400 });
}
const safeMime =
typeof mimeType === 'string' && /^image\/(jpeg|png|webp)$/.test(mimeType) ? mimeType : 'image/jpeg';
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
let instruction: string;
if (op?.type === 'prompt') {
const p = typeof op.prompt === 'string' ? op.prompt.trim() : '';
if (!p) return NextResponse.json({ error: 'Empty prompt.' }, { status: 400 });
instruction = p.slice(0, 500);
} else if (op?.type === 'replace-sky' && typeof op.prompt === 'string' && op.prompt.trim()) {
instruction = `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`;
} else if (op?.type && OP_PROMPTS[op.type]) {
instruction = OP_PROMPTS[op.type]!;
} else {
return NextResponse.json({ error: 'Unsupported operation.' }, { status: 400 });
}
try {
let result: EditResult;
if (provider === 'local') result = await editLocal(instruction, imageBase64);
else if (provider === 'huggingface') result = await editHuggingFace(instruction, imageBase64);
else result = await editGemini(instruction, imageBase64, safeMime);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed.';
const status = err instanceof AiError ? err.status : 502;
return NextResponse.json({ error: message }, { status });
}
}
class AiError extends Error {
status: number;
constructor(message: string, status = 502) {
super(message);
this.status = status;
}
}
// ---------------------------------------------------------------------------
// Backend: local Stable Diffusion (Automatic1111 / Forge / SD.Next img2img API)
// ---------------------------------------------------------------------------
async function editLocal(instruction: string, imageBase64: string): Promise<EditResult> {
const base = process.env.LOCAL_SD_URL;
if (!base || !/^https?:\/\//i.test(base)) {
throw new AiError('LOCAL_SD_URL is not a valid http(s) URL.', 500);
}
const url = `${base.replace(/\/$/, '')}/sdapi/v1/img2img`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
init_images: [imageBase64],
prompt: instruction,
denoising_strength: Number(process.env.LOCAL_SD_DENOISE ?? 0.55),
steps: Number(process.env.LOCAL_SD_STEPS ?? 25),
cfg_scale: 7,
sampler_name: process.env.LOCAL_SD_SAMPLER || 'Euler a',
}),
});
} catch {
throw new AiError('Could not reach your local Stable Diffusion server (LOCAL_SD_URL).', 502);
}
if (!res.ok) {
throw new AiError(`Local SD server error (${res.status}).`, 502);
}
const data = (await res.json().catch(() => null)) as { images?: string[] } | null;
const out = data?.images?.[0];
if (!out) throw new AiError('Local SD server did not return an image.', 502);
// A1111 returns raw base64 PNG (no data: prefix).
return { imageBase64: out.includes(',') ? out.split(',')[1]! : out, mimeType: 'image/png' };
}
// ---------------------------------------------------------------------------
// Backend: Hugging Face Inference API (free tier) — instruction image editing.
// ---------------------------------------------------------------------------
async function editHuggingFace(instruction: string, imageBase64: string): Promise<EditResult> {
const token = process.env.HF_API_TOKEN;
if (!token) throw new AiError('HF_API_TOKEN is not set.', 500);
const model = process.env.HF_IMAGE_MODEL || 'timbrooks/instruct-pix2pix';
let res: Response;
try {
res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
// Wait for the (free) model to warm up instead of a fast 503.
'x-wait-for-model': 'true',
},
body: JSON.stringify({
inputs: imageBase64,
parameters: { prompt: instruction, guidance_scale: 7, image_guidance_scale: 1.5 },
}),
});
} catch {
throw new AiError('Could not reach the Hugging Face Inference API.', 502);
}
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 160);
if (res.status === 503)
throw new AiError('The free model is loading — try again in ~20s.', 503);
throw new AiError(`Hugging Face error (${res.status}). ${detail}`, 502);
}
// Success returns raw image bytes.
const outMime = res.headers.get('content-type') || 'image/png';
if (outMime.startsWith('application/json')) {
const j = (await res.json().catch(() => null)) as { error?: string } | null;
throw new AiError(j?.error ? `Hugging Face: ${j.error}` : 'Hugging Face returned no image.', 502);
}
const buf = await res.arrayBuffer();
return { imageBase64: Buffer.from(buf).toString('base64'), mimeType: outMime };
}
// ---------------------------------------------------------------------------
// Backend: Google Gemini image model (needs a billed key for image output).
// ---------------------------------------------------------------------------
async function editGemini(instruction: string, imageBase64: string, safeMime: string): Promise<EditResult> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) throw new AiError('GEMINI_API_KEY is not set.', 500);
const model = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
const prompt = `Edit this image as follows: ${instruction}. Preserve realism unless explicitly asked otherwise.`;
let res: Response;
try {
res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: 'POST',
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
body: JSON.stringify({
contents: [
{ role: 'user', parts: [{ inlineData: { mimeType: safeMime, data: imageBase64 } }, { text: prompt }] },
],
generationConfig: { responseModalities: ['IMAGE'] },
}),
},
);
} catch {
throw new AiError('Could not reach the AI service.', 502);
}
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 160);
throw new AiError(`AI service error (${res.status}). ${detail}`, 502);
}
const data = (await res.json().catch(() => null)) as GeminiResponse | null;
const parts = data?.candidates?.[0]?.content?.parts ?? [];
const imgPart = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
const out = imgPart?.inlineData?.data ?? imgPart?.inline_data?.data;
if (!out) throw new AiError('The model did not return an image (the free Gemini tier has no image output — use LOCAL_SD_URL or HF_API_TOKEN instead).', 502);
const outMime = imgPart?.inlineData?.mimeType ?? imgPart?.inline_data?.mime_type ?? 'image/png';
return { imageBase64: out, mimeType: outMime };
}
interface GeminiPart {
text?: string;
inlineData?: { mimeType?: string; data?: string };
inline_data?: { mime_type?: string; data?: string };
}
interface GeminiResponse {
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
}
+5
View File
@@ -0,0 +1,5 @@
import { GalleryClient } from '../../components/GalleryClient';
export default function GalleryPage() {
return <GalleryClient />;
}
+335
View File
@@ -0,0 +1,335 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Inter', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
/* ===================== Landing page ===================== */
.apg-landing {
position: relative;
color: #f4f4f7;
background: #07070b;
overflow-x: hidden;
padding-bottom: 60px;
}
.landing-bg {
position: fixed;
inset: 0;
z-index: 0;
background:
radial-gradient(60vw 60vw at 75% -10%, rgba(10, 132, 255, 0.28), transparent 60%),
radial-gradient(50vw 50vw at 0% 20%, rgba(191, 90, 242, 0.18), transparent 60%),
#07070b;
pointer-events: none;
}
.apg-landing > *:not(.landing-bg) {
position: relative;
z-index: 1;
}
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px clamp(16px, 5vw, 64px);
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-weight: 700;
font-size: 17px;
display: flex;
align-items: center;
gap: 9px;
}
.brand-dot {
width: 16px;
height: 16px;
border-radius: 5px;
background: linear-gradient(135deg, #0a84ff, #bf5af2);
}
.brand-muted {
color: #8e8e96;
font-weight: 600;
}
.landing-links {
display: flex;
align-items: center;
gap: 18px;
}
.muted-link {
color: #c7c7cf;
text-decoration: none;
font-size: 14px;
}
.muted-link:hover {
color: #fff;
}
.cta {
display: inline-flex;
align-items: center;
gap: 6px;
background: linear-gradient(135deg, #0a84ff, #0a6fff);
color: #fff;
text-decoration: none;
font-weight: 600;
padding: 12px 22px;
border-radius: 12px;
font-size: 16px;
box-shadow: 0 10px 30px rgba(10, 132, 255, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cta:hover {
transform: translateY(-1px);
box-shadow: 0 14px 40px rgba(10, 132, 255, 0.5);
}
.cta-sm {
padding: 8px 16px;
font-size: 14px;
border-radius: 10px;
}
.ghost {
color: #d7d7df;
text-decoration: none;
font-weight: 500;
padding: 12px 18px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
}
.ghost:hover {
background: rgba(255, 255, 255, 0.07);
}
.hero {
max-width: 1000px;
margin: 0 auto;
text-align: center;
padding: clamp(40px, 8vw, 90px) clamp(16px, 5vw, 40px) 30px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 12px;
color: #8ab4ff;
font-weight: 600;
margin: 0 0 18px;
}
.hero h1 {
font-size: clamp(34px, 6.5vw, 68px);
line-height: 1.04;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0 0 22px;
}
.grad {
background: linear-gradient(120deg, #0a84ff, #bf5af2 60%, #ff9f0a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lead {
font-size: clamp(16px, 2.4vw, 20px);
color: #b9b9c3;
max-width: 680px;
margin: 0 auto 30px;
line-height: 1.5;
}
.hero-cta {
display: flex;
gap: 14px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 54px;
}
.window {
max-width: 920px;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 40px 120px rgba(0, 0, 0, 0.6);
background: #1c1c1e;
}
.window-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
background: #2a2a2c;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tl {
width: 12px;
height: 12px;
border-radius: 50%;
}
.tl-r {
background: #ff5f57;
}
.tl-y {
background: #febc2e;
}
.tl-g {
background: #28c840;
}
.window-title {
margin: 0 auto;
font-size: 13px;
color: #9b9ba1;
font-weight: 600;
}
.window-body {
display: flex;
min-height: 320px;
text-align: left;
}
.wb-sidebar {
width: 200px;
flex-shrink: 0;
padding: 14px 10px;
background: rgba(255, 255, 255, 0.02);
border-right: 1px solid rgba(255, 255, 255, 0.06);
font-size: 13.5px;
}
.wb-row {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 7px;
color: #d7d7df;
}
.wb-row--active {
background: #0a84ff;
color: #fff;
}
.wb-ico {
width: 15px;
height: 15px;
border-radius: 4px;
background: currentColor;
opacity: 0.7;
}
.wb-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 6px;
padding: 12px;
}
.wb-tile {
aspect-ratio: 1;
border-radius: 6px;
background: linear-gradient(135deg, #3a3a3c, #2a2a2c);
animation: wb-fade 0.5s ease both;
}
@keyframes wb-fade {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.usage {
max-width: 760px;
margin: 70px auto 0;
padding: 0 clamp(16px, 5vw, 24px);
text-align: center;
}
.usage h2,
.final h2 {
font-size: clamp(24px, 4vw, 34px);
font-weight: 800;
letter-spacing: -0.01em;
}
.code {
text-align: left;
background: #0f0f17;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 20px 22px;
overflow-x: auto;
font-size: 13.5px;
line-height: 1.6;
color: #d7e0ff;
font-family: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
}
.usage-note {
color: #9b9ba1;
font-size: 14px;
margin-top: 16px;
}
.usage-note code,
.landing-foot code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 5px;
font-size: 0.9em;
}
.features {
max-width: 1100px;
margin: 70px auto 0;
padding: 0 clamp(16px, 5vw, 24px);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.feature {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 20px;
}
.feature h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
}
.feature p {
margin: 0;
color: #a9a9b3;
font-size: 14px;
line-height: 1.5;
}
.final {
text-align: center;
margin: 80px auto 0;
padding: 0 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.final p {
color: #a9a9b3;
margin: 0;
}
.landing-foot {
text-align: center;
color: #6e6e78;
font-size: 13px;
margin-top: 60px;
padding: 0 20px;
}
@media (max-width: 620px) {
.wb-sidebar {
display: none;
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a84ff"/>
<stop offset="1" stop-color="#bf5af2"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<circle cx="11" cy="12" r="2.4" fill="#fff"/>
<path d="M5 23l6.5-6 4 3.2L21 13l6 8z" fill="#fff" opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

+32
View File
@@ -0,0 +1,32 @@
import type { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import './globals.css';
// The SDK's self-contained stylesheet + Leaflet's CSS for the Map view.
import '@photo-gallery/sdk/styles.css';
import 'leaflet/dist/leaflet.css';
export const metadata: Metadata = {
title: 'Photo Gallery SDK — macOS Photos for the web',
description:
'A reusable, macOS Photos-style photo gallery for React / Next.js. Albums, smart albums, map, editor, search and more — drop-in via one component.',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#0a84ff',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Reading the per-request nonce header opts every route into dynamic rendering,
// which lets Next apply the CSP nonce (set in middleware.ts) to its framework
// scripts — required for the nonce-based, no-'unsafe-inline' script policy.
void (await headers()).get('x-nonce');
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+126
View File
@@ -0,0 +1,126 @@
import Link from 'next/link';
const FEATURES: Array<{ title: string; body: string }> = [
{ title: 'Pixel-faithful UI', body: 'macOS Photos sidebar, Library (Years/Months/All), Collections, Map and toolbars — in light & dark mode.' },
{ title: 'Albums & Smart Albums', body: 'Create, nest, rename, duplicate. Auto smart albums for Screenshots, Videos, Favourites, RAW and more.' },
{ title: 'Auto source detection', body: 'Camera vs. screenshot vs. download vs. social — classified on import with zero AI cost.' },
{ title: 'Map & places', body: 'Plot located photos on a free OpenStreetMap / satellite map. Map · Satellite · Grid modes.' },
{ title: 'Non-destructive editor', body: 'Exposure, contrast, colour, filters, rotate & flip — applied live, reversible any time.' },
{ title: 'Search & find-similar', body: 'Search by name, place, tag or detected object. Click an object to surface every photo with it.' },
{ title: 'Recycle bin & duplicates', body: '30-day recoverable trash, multi-select, copy/move, and duplicate detection with one-tap merge.' },
{ title: 'Pluggable everything', body: 'Swap the storage adapter (localStorage → S3/Postgres) and AI provider (free in-browser → Gemini).' },
];
export default function Home() {
return (
<main style={{ minHeight: '100%' }} className="apg-landing">
<div className="landing-bg" />
<header className="landing-nav">
<div className="brand">
<span className="brand-dot" />
Photo Gallery <span className="brand-muted">SDK</span>
</div>
<nav className="landing-links">
<a href="https://github.com" className="muted-link" target="_blank" rel="noreferrer">
Docs
</a>
<Link href="/gallery" className="cta cta-sm">
Open Gallery
</Link>
</nav>
</header>
<section className="hero">
<p className="eyebrow">React · Next.js · TypeScript · tree-shakeable</p>
<h1>
The macOS Photos experience,
<br />
as a drop-in <span className="grad">SDK</span>.
</h1>
<p className="lead">
A reusable, enterprise-grade photo gallery for any React or Next.js app. Albums, smart
albums, map, a non-destructive editor, search and recycle bin behind a single component.
</p>
<div className="hero-cta">
<Link href="/gallery" className="cta">
Open the Gallery
</Link>
<a href="#usage" className="ghost">
See the code
</a>
</div>
<div className="window">
<div className="window-bar">
<span className="tl tl-r" />
<span className="tl tl-y" />
<span className="tl tl-g" />
<span className="window-title">Photos</span>
</div>
<div className="window-body">
<div className="wb-sidebar">
{['Library', 'Collections', 'Favourites', 'Map', 'Videos', 'Screenshots', 'People'].map(
(s, i) => (
<div key={s} className={`wb-row ${i === 0 ? 'wb-row--active' : ''}`}>
<span className="wb-ico" />
{s}
</div>
),
)}
</div>
<div className="wb-grid">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="wb-tile" style={{ animationDelay: `${i * 18}ms` }} />
))}
</div>
</div>
</div>
</section>
<section id="usage" className="usage">
<h2>Three lines to a full gallery</h2>
<pre className="code">
<code>{`import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
export default function App() {
return (
<PhotoGallery
photos={myPhotos} // your data, any source
theme="system" // light · dark · system
features={{ editor: true, map: true, import: true }}
/>
);
}`}</code>
</pre>
<p className="usage-note">
Defaults to a zero-config localStorage backend, so it runs with no server. Provide a
<code> StorageAdapter</code> or <code> AIProvider</code> to scale up.
</p>
</section>
<section className="features">
{FEATURES.map((f) => (
<div key={f.title} className="feature">
<h3>{f.title}</h3>
<p>{f.body}</p>
</div>
))}
</section>
<section className="final">
<h2>Try it now</h2>
<p>The demo ships with 40+ sample photos, videos, locations and tags.</p>
<Link href="/gallery" className="cta">
Open the Gallery
</Link>
</section>
<footer className="landing-foot">
Built as <code>@photo-gallery/sdk</code> · original implementation, not affiliated with
Apple.
</footer>
</main>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { PhotoGallery, type StorageAdapter } from '@photo-gallery/sdk';
import { useEffect, useMemo, useState } from 'react';
import { createDemoAIProvider } from '../lib/ai/createDemoAIProvider';
import { getGalleryConfigFromEnv } from '../lib/galleryConfig';
import { buildSeedPhotos } from '../lib/seed';
export function GalleryClient() {
// Render the gallery only after mount — it relies on browser APIs
// (localStorage, matchMedia, Leaflet) and should not be server-rendered.
const [mounted, setMounted] = useState(false);
// undefined = still resolving; null = no backend (use default localStorage).
const [adapter, setAdapter] = useState<StorageAdapter | null | undefined>(undefined);
useEffect(() => {
setMounted(true);
let cancelled = false;
void (async () => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
setAdapter(null);
return;
}
// Lazy-load the Supabase client so it isn't in the initial bundle.
const { createSupabaseAdapter } = await import('../lib/adapters/supabaseAdapter');
if (!cancelled) {
setAdapter(
createSupabaseAdapter({
url,
anonKey,
bucket: process.env.NEXT_PUBLIC_SUPABASE_BUCKET || 'media',
}),
);
}
})();
return () => {
cancelled = true;
};
}, []);
const photos = useMemo(() => buildSeedPhotos(), []);
// AI: in-browser object detection (free) + Gemini generative edits (server-proxied).
const ai = useMemo(() => createDemoAIProvider(), []);
// Wait for mount + adapter resolution so we initialize on the right backend once.
if (!mounted || adapter === undefined) {
return (
<div
style={{
position: 'fixed',
inset: 0,
display: 'grid',
placeItems: 'center',
background: '#1c1c1e',
color: '#aeaeb2',
fontFamily: 'system-ui, sans-serif',
}}
>
Loading Photos
</div>
);
}
// Optional NEXT_PUBLIC_APG_* env overrides (theme colors/gradients, sidebar,
// per-feature flags). Unset values fall back to the defaults below. See docs/ENV.md.
const env = getGalleryConfigFromEnv();
return (
<div style={{ position: 'fixed', inset: 0 }}>
<PhotoGallery
photos={photos}
theme={env.theme ?? 'system'}
ai={ai}
adapter={adapter ?? undefined}
borderRadius={env.borderRadius}
themeTokens={env.themeTokens}
features={{
editor: true,
camera: true,
ai: true,
map: true,
import: true,
export: true,
sharing: true,
...env.features,
}}
/>
</div>
);
}
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
import type { MediaItem } from '@photo-gallery/sdk';
/**
* Free, in-browser semantic search via CLIP (transformers.js / ONNX-WASM).
*
* CLIP maps images and text into the SAME 512-D space, so "show me beach photos"
* ranks visually-matching images even when they have no tags or filename hints.
* The model is downloaded once from the Hugging Face CDN (allow-listed in the CSP)
* and cached by the browser; the module is dynamically imported on first use so it
* never bloats the initial bundle. Nothing leaves the browser.
*/
const MODEL_ID = 'Xenova/clip-vit-base-patch16';
type TF = typeof import('@huggingface/transformers');
let tfMod: TF | null = null;
let visionPromise: Promise<{ processor: any; model: any } | null> | null = null;
let textPromise: Promise<{ tokenizer: any; model: any } | null> | null = null;
async function loadTf(): Promise<TF> {
if (!tfMod) {
tfMod = await import('@huggingface/transformers');
// Remote-only (models from HF CDN); rely on browser cache between sessions.
tfMod.env.allowLocalModels = false;
}
return tfMod;
}
function ensureVision() {
visionPromise ??= (async () => {
try {
const tf = await loadTf();
const [processor, model] = await Promise.all([
tf.AutoProcessor.from_pretrained(MODEL_ID),
tf.CLIPVisionModelWithProjection.from_pretrained(MODEL_ID),
]);
return { processor, model };
} catch (err) {
console.warn('[clipProvider] vision model load failed; semantic search disabled.', err);
return null;
}
})();
return visionPromise;
}
function ensureText() {
textPromise ??= (async () => {
try {
const tf = await loadTf();
const [tokenizer, model] = await Promise.all([
tf.AutoTokenizer.from_pretrained(MODEL_ID),
tf.CLIPTextModelWithProjection.from_pretrained(MODEL_ID),
]);
return { tokenizer, model };
} catch (err) {
console.warn('[clipProvider] text model load failed; semantic search disabled.', err);
return null;
}
})();
return textPromise;
}
/** Draw an image onto a canvas (downscaled) for the CLIP image processor. */
function toCanvas(image: ImageBitmap | HTMLImageElement, maxDim = 384): HTMLCanvasElement {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const ctx = canvas.getContext('2d')!;
ctx.drawImage(image as CanvasImageSource, 0, 0, canvas.width, canvas.height);
return canvas;
}
function tensorToArray(t: any): number[] {
const data: Float32Array = t?.data ?? t;
return Array.from(data as ArrayLike<number>);
}
export function createClipProvider() {
return {
async embedImage(_item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<number[]> {
const v = await ensureVision();
if (!v) return [];
try {
const tf = await loadTf();
const canvas = toCanvas(image);
const imageData = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height);
const raw = new tf.RawImage(imageData.data, canvas.width, canvas.height, 4).rgb();
const inputs = await v.processor(raw);
const out = await v.model(inputs);
return tensorToArray(out.image_embeds);
} catch (err) {
console.warn('[clipProvider] embedImage failed.', err);
return [];
}
},
async embedText(query: string): Promise<number[]> {
const t = await ensureText();
if (!t) return [];
try {
const inputs = t.tokenizer([query], { padding: true, truncation: true });
const out = await t.model(inputs);
return tensorToArray(out.text_embeds);
} catch (err) {
console.warn('[clipProvider] embedText failed.', err);
return [];
}
},
};
}
+102
View File
@@ -0,0 +1,102 @@
import type { AIProvider } from '@photo-gallery/sdk';
import { createClipProvider } from './clipProvider';
import { createFaceProvider } from './faceProvider';
import { createOCRProvider } from './ocrProvider';
import { createTensorflowProvider } from './tensorflowProvider';
/**
* The demo's AI provider, combining free in-browser capabilities + Gemini:
* - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key)
* - face detection + recognition: face-api.js, in-browser → clustered into People
* - OCR: tesseract.js, in-browser → searchable text + the Documents album (no key)
* - background removal: @imgly, in-browser WASM (no key) — see generativeEdit
* - other generative edits: Gemini, proxied through /api/ai/edit so the API key
* stays server-side and is never exposed to the browser.
*/
export function createDemoAIProvider(): AIProvider {
const tf = createTensorflowProvider();
const face = createFaceProvider();
const ocr = createOCRProvider();
const clip = createClipProvider();
return {
name: 'demo-ai (coco-ssd + face-api + tesseract + clip + gemini)',
detectObjects: tf.detectObjects,
detectFaces: face.detectFaces,
ocr: ocr.ocr,
embedImage: clip.embedImage,
embedText: clip.embedText,
async generativeEdit(item, image, op) {
// Remove Background runs fully in-browser (free, no key) via @imgly — works
// even when Gemini is unavailable. Other ops go through the Gemini route.
if (op.type === 'remove-background') {
const inputBlob = await canvasBlob(image, 1600);
const { removeBackground } = await import('@imgly/background-removal');
return removeBackground(inputBlob, { output: { format: 'image/png' } });
}
const { data, mimeType } = imageToBase64(image, 1280);
const res = await fetch('/api/ai/edit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ imageBase64: data, mimeType, op }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `AI request failed (${res.status}).`);
}
const { imageBase64, mimeType: outMime } = (await res.json()) as {
imageBase64: string;
mimeType?: string;
};
return base64ToBlob(imageBase64, outMime || 'image/png');
},
};
}
/** Draw an image to a canvas (downscaled) and return base64 JPEG (no data: prefix). */
function imageToBase64(
image: ImageBitmap | HTMLImageElement,
maxDim: number,
): { data: string; mimeType: string } {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported.');
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
return { data: dataUrl.split(',')[1] ?? '', mimeType: 'image/jpeg' };
}
/** Draw an image to a canvas (downscaled) and return a JPEG Blob. */
function canvasBlob(image: ImageBitmap | HTMLImageElement, maxDim: number): Promise<Blob> {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) return Promise.reject(new Error('Canvas not supported.'));
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
return new Promise((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.92),
);
}
function base64ToBlob(base64: string, mime: string): Blob {
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Blob([bytes], { type: mime });
}
+103
View File
@@ -0,0 +1,103 @@
import type { DetectedFace, MediaItem } from '@photo-gallery/sdk';
// Type-only imports are erased at compile time — no bundle cost.
import type { TNetInput } from '@vladmandic/face-api';
/**
* Free, in-browser face detection + recognition via `@vladmandic/face-api`
* (TensorFlow.js under the hood). It returns a 128-D descriptor per face which
* the SDK clusters into People — no API key, nothing leaves the browser.
*
* Models are fetched once from the jsDelivr CDN (allow-listed in the CSP). The
* whole module is dynamically imported on first use so it never bloats the
* initial bundle.
*/
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model';
type FaceApi = typeof import('@vladmandic/face-api');
let loadPromise: Promise<FaceApi | null> | null = null;
/** Load face-api + its models exactly once; resolves to null if anything fails. */
function ensureModels(): Promise<FaceApi | null> {
loadPromise ??= (async () => {
try {
const faceapi = await import('@vladmandic/face-api');
// The bundled tf re-export is typed narrowly; backend control lives on the
// runtime object. Prefer WebGL (no eval; CSP-friendly), fall back gracefully.
const tf = faceapi.tf as unknown as {
setBackend: (b: string) => Promise<boolean>;
ready: () => Promise<void>;
};
try {
await tf.setBackend('webgl');
} catch {
/* keep default backend */
}
await tf.ready();
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
]);
return faceapi;
} catch (err) {
// Degrade gracefully — People simply stays empty if models can't load.
console.warn('[faceProvider] model load failed; face clustering disabled.', err);
return null;
}
})();
return loadPromise;
}
let warned = false;
export function createFaceProvider() {
return {
async detectFaces(
item: MediaItem,
image: ImageBitmap | HTMLImageElement,
): Promise<DetectedFace[]> {
const faceapi = await ensureModels();
if (!faceapi) return [];
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width || 1;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height || 1;
let results;
try {
results = await faceapi
.detectAllFaces(
image as unknown as TNetInput,
new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 }),
)
.withFaceLandmarks()
.withFaceDescriptors();
} catch (err) {
if (!warned) {
warned = true;
console.warn('[faceProvider] face detection failed on', item.name, err);
}
return [];
}
return results.map((r) => {
const b = r.detection.box;
return {
confidence: r.detection.score,
box: {
x: clamp01(b.x / w),
y: clamp01(b.y / h),
width: clamp01(b.width / w),
height: clamp01(b.height / h),
},
embedding: Array.from(r.descriptor),
} satisfies DetectedFace;
});
},
};
}
function clamp01(n: number): number {
return n < 0 ? 0 : n > 1 ? 1 : n;
}
+122
View File
@@ -0,0 +1,122 @@
import type { MediaItem } from '@photo-gallery/sdk';
/**
* Free, in-browser OCR via tesseract.js (no API key, nothing leaves the browser).
*
* tesseract spins up a Web Worker that compiles a WASM core and downloads the
* English language model. ALL three assets are pinned to `cdn.jsdelivr.net`,
* which is already allow-listed in the CSP (faceProvider uses it too) — so no
* fetch silently falls back to tesseract's default `tessdata.projectnaptha.com`
* (which is NOT allow-listed and would be blocked). The module is dynamically
* imported on first use so it never bloats the initial bundle.
*
* IMPORTANT: tesseract hallucinates low-confidence gibberish for photos that
* contain no text. We therefore keep only high-confidence, word-shaped tokens
* and require several of them — otherwise a landscape photo would wrongly count
* as a "document". See `meaningfulText`.
*/
// Must equal the EXACT tesseract.js version in apps/web/package.json (pinned, no
// caret) so the worker CDN URL can never drift from the installed main-thread code.
const VERSION = '5.1.1';
const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${VERSION}/dist/worker.min.js`;
const CORE_PATH = 'https://cdn.jsdelivr.net/npm/tesseract.js-core@5';
// jsDelivr's GitHub mirror of naptha/tessdata (same files as projectnaptha.com).
const LANG_PATH = 'https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0';
/** A word per tesseract's output hierarchy. */
interface OcrWord {
text?: string;
confidence?: number;
}
interface OcrData {
text?: string;
confidence?: number;
words?: OcrWord[];
blocks?: Array<{
paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }>;
}> | null;
}
type Worker = import('tesseract.js').Worker;
let workerPromise: Promise<Worker | null> | null = null;
/** Create + initialize the tesseract worker exactly once; null if it fails. */
function ensureWorker(): Promise<Worker | null> {
workerPromise ??= (async () => {
try {
const { createWorker } = await import('tesseract.js');
// v5: createWorker(langs, oem, options) already loads + initializes the
// language internally — do NOT call the removed v4 worker.load().
return await createWorker('eng', 1, {
workerPath: WORKER_PATH,
corePath: CORE_PATH,
langPath: LANG_PATH,
});
} catch (err) {
console.warn('[ocrProvider] worker init failed; OCR disabled.', err);
return null;
}
})();
return workerPromise;
}
const WORD_CONFIDENCE = 70; // a word tesseract is actually sure about
const MIN_WORDS = 4; // need several confident words to call it a document
const MIN_CHARS = 10;
/** Flatten tesseract's word list from whichever shape this version returns. */
function collectWords(data: OcrData): OcrWord[] {
if (Array.isArray(data.words) && data.words.length) return data.words;
const out: OcrWord[] = [];
for (const b of data.blocks ?? [])
for (const p of b.paragraphs ?? [])
for (const l of p.lines ?? [])
for (const w of l.words ?? []) out.push(w);
return out;
}
/**
* Return real text or '' (not a document). Prefers per-word confidence; falls
* back to the overall mean confidence + a word-shape heuristic if the build
* doesn't expose words.
*/
function meaningfulText(data: OcrData): string {
const words = collectWords(data);
if (words.length > 0) {
const good = words.filter(
(w) => (w.confidence ?? 0) >= WORD_CONFIDENCE && /[A-Za-z0-9]{2,}/.test(w.text ?? ''),
);
const text = good
.map((w) => (w.text ?? '').trim())
.filter(Boolean)
.join(' ')
.trim();
return good.length >= MIN_WORDS && text.length >= MIN_CHARS ? text : '';
}
// Fallback: overall confidence + count of word-shaped tokens.
const raw = (data.text ?? '').trim();
const conf = typeof data.confidence === 'number' ? data.confidence : 0;
const realWords = raw.match(/[A-Za-z]{3,}/g) ?? [];
return conf >= 72 && realWords.length >= 6 ? raw : '';
}
export function createOCRProvider() {
return {
async ocr(_item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<string> {
const worker = await ensureWorker();
if (!worker) return '';
try {
// Request the block hierarchy so per-word confidence is available.
const { data } = (await worker.recognize(image as unknown as HTMLImageElement, {}, {
text: true,
blocks: true,
})) as { data: OcrData };
return meaningfulText(data);
} catch {
return '';
}
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import type { AIProvider, DetectedObject, MediaItem } from '@photo-gallery/sdk';
/**
* Free, in-browser AI provider using TensorFlow.js + COCO-SSD object detection.
* No API key, no server — the model (~6 MB) downloads from the public TF model
* CDN on first use and runs on the GPU via the WebGL backend.
*
* Detects 80 COCO classes (person, dog, cat, car, laptop, chair, dining table,
* tv, bottle, cup, …) which power search, find-similar and the Objects browser.
*/
export function createTensorflowProvider(): AIProvider {
// Loaded lazily and memoized so the heavy bundle/model is fetched only once.
let modelPromise: Promise<CocoModel> | null = null;
async function getModel(): Promise<CocoModel> {
if (!modelPromise) {
modelPromise = (async () => {
const tf = await import('@tensorflow/tfjs');
try {
await tf.setBackend('webgl');
} catch {
// Fall back to the default backend if WebGL is unavailable.
}
await tf.ready();
const cocoSsd = await import('@tensorflow-models/coco-ssd');
return cocoSsd.load({ base: 'lite_mobilenet_v2' }) as unknown as CocoModel;
})();
}
return modelPromise;
}
return {
name: 'tensorflow-coco-ssd',
async detectObjects(item: MediaItem, image): Promise<DetectedObject[]> {
const model = await getModel();
const el = image as HTMLImageElement;
const w = el.naturalWidth || el.width || item.width || 1;
const h = el.naturalHeight || el.height || item.height || 1;
const predictions = await model.detect(el, 20, 0.4);
return predictions.map((p) => ({
label: p.class,
confidence: p.score,
box: {
x: p.bbox[0] / w,
y: p.bbox[1] / h,
width: p.bbox[2] / w,
height: p.bbox[3] / h,
},
}));
},
};
}
interface CocoPrediction {
bbox: [number, number, number, number];
class: string;
score: number;
}
interface CocoModel {
detect(
img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement,
maxNumBoxes?: number,
minScore?: number,
): Promise<CocoPrediction[]>;
}
+73
View File
@@ -0,0 +1,73 @@
import type { GalleryFeatures, ThemeTokens } from '@photo-gallery/sdk';
/**
* Reads NEXT_PUBLIC_APG_* environment variables into <PhotoGallery> props so the
* whole gallery can be customized without touching code. Every value is optional
* — anything unset falls back to the SDK's built-in defaults (dark/light/semi-dark).
*
* See docs/ENV.md for the full list.
*
* NOTE: Next.js only inlines NEXT_PUBLIC_* vars that are referenced statically, so
* each one is read explicitly below (not via a dynamic key).
*/
const bool = (v: string | undefined): boolean | undefined =>
v === undefined || v === '' ? undefined : v === 'true' || v === '1';
const num = (v: string | undefined): number | undefined => {
if (v === undefined || v === '') return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
/** Drop undefined entries so partial objects don't clobber SDK defaults. */
function compact<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
}
export function getFeaturesFromEnv(): Partial<GalleryFeatures> {
return compact({
editor: bool(process.env.NEXT_PUBLIC_APG_EDITOR),
camera: bool(process.env.NEXT_PUBLIC_APG_CAMERA),
ai: bool(process.env.NEXT_PUBLIC_APG_AI),
map: bool(process.env.NEXT_PUBLIC_APG_MAP),
import: bool(process.env.NEXT_PUBLIC_APG_IMPORT),
export: bool(process.env.NEXT_PUBLIC_APG_EXPORT),
sharing: bool(process.env.NEXT_PUBLIC_APG_SHARING),
});
}
export function getThemeTokensFromEnv(): ThemeTokens | undefined {
const tokens = compact({
bgLight: process.env.NEXT_PUBLIC_APG_BG_LIGHT || undefined,
bgDark: process.env.NEXT_PUBLIC_APG_BG_DARK || undefined,
elevatedLight: process.env.NEXT_PUBLIC_APG_ELEVATED_LIGHT || undefined,
elevatedDark: process.env.NEXT_PUBLIC_APG_ELEVATED_DARK || undefined,
sidebarBgLight: process.env.NEXT_PUBLIC_APG_SIDEBAR_BG_LIGHT || undefined,
sidebarBgDark: process.env.NEXT_PUBLIC_APG_SIDEBAR_BG_DARK || undefined,
textLight: process.env.NEXT_PUBLIC_APG_TEXT_LIGHT || undefined,
textDark: process.env.NEXT_PUBLIC_APG_TEXT_DARK || undefined,
sidebarRadius: num(process.env.NEXT_PUBLIC_APG_SIDEBAR_RADIUS),
accent: process.env.NEXT_PUBLIC_APG_ACCENT || undefined,
}) as ThemeTokens;
return Object.keys(tokens).length ? tokens : undefined;
}
export interface EnvGalleryConfig {
features: Partial<GalleryFeatures>;
themeTokens?: ThemeTokens;
borderRadius?: number;
theme?: 'system' | 'light' | 'dark' | 'semi-dark';
}
export function getGalleryConfigFromEnv(): EnvGalleryConfig {
const theme = process.env.NEXT_PUBLIC_APG_THEME as EnvGalleryConfig['theme'] | undefined;
// Accent comes through themeTokens.accent (PhotoGallery reads it there) — no
// separate accentColor field, to avoid a confusing double source for one env var.
return {
features: getFeaturesFromEnv(),
themeTokens: getThemeTokensFromEnv(),
borderRadius: num(process.env.NEXT_PUBLIC_APG_RADIUS),
theme: theme || undefined,
};
}
+141
View File
@@ -0,0 +1,141 @@
import type { MediaInput } from '@photo-gallery/sdk';
/**
* Demo seed data. Uses free Picsum photos with synthetic-but-realistic metadata
* (capture dates, GPS locations, tags, detected objects, sources) so that the
* Map, Search, Smart Albums and "find similar object" features all have data to
* show without any backend or API key.
*
* Picsum is allow-listed in the CSP (see next.config.mjs). Videos use Google's
* public sample bucket, also allow-listed.
*/
const CITIES: Array<{ place: string; city: string; country: string; lat: number; lng: number }> = [
{ place: 'Mumbai, India', city: 'Mumbai', country: 'India', lat: 19.076, lng: 72.8777 },
{ place: 'New Delhi, India', city: 'New Delhi', country: 'India', lat: 28.6139, lng: 77.209 },
{ place: 'Bengaluru, India', city: 'Bengaluru', country: 'India', lat: 12.9716, lng: 77.5946 },
{ place: 'Goa, India', city: 'Goa', country: 'India', lat: 15.2993, lng: 74.124 },
{ place: 'Manali, India', city: 'Manali', country: 'India', lat: 32.2432, lng: 77.1892 },
{ place: 'Jaipur, India', city: 'Jaipur', country: 'India', lat: 26.9124, lng: 75.7873 },
{ place: 'San Francisco, USA', city: 'San Francisco', country: 'USA', lat: 37.7749, lng: -122.4194 },
{ place: 'New York, USA', city: 'New York', country: 'USA', lat: 40.7128, lng: -74.006 },
{ place: 'London, UK', city: 'London', country: 'UK', lat: 51.5074, lng: -0.1278 },
{ place: 'Paris, France', city: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522 },
{ place: 'Tokyo, Japan', city: 'Tokyo', country: 'Japan', lat: 35.6762, lng: 139.6503 },
{ place: 'Dubai, UAE', city: 'Dubai', country: 'UAE', lat: 25.2048, lng: 55.2708 },
];
const TAG_POOL = [
['beach', 'sunset', 'ocean'],
['mountain', 'snow', 'hike'],
['food', 'restaurant'],
['city', 'building', 'night'],
['nature', 'flower', 'plant'],
['friends', 'party'],
['travel', 'landmark'],
['selfie', 'portrait'],
];
const OBJECT_POOL = [
['person'],
['dog'],
['cat'],
['car'],
['laptop', 'cup'],
['dining table', 'chair', 'bottle'],
['tv', 'chair'],
['potted plant'],
['bicycle', 'person'],
];
// Served from the demo's own /public so it plays same-origin (no CORS / ORB issues).
const SAMPLE_VIDEOS = ['/sample-clip.mp4'];
const DAY = 24 * 60 * 60 * 1000;
export function buildSeedPhotos(now = Date.now()): MediaInput[] {
const items: MediaInput[] = [];
for (let i = 0; i < 44; i++) {
const seed = `apg-${i}`;
const takenAt = now - Math.floor(i * 8.4 + (i % 5) * 3) * DAY;
// ~75% of demo photos are geotagged (real photos populate location via EXIF GPS on import).
const hasLoc = i % 4 !== 0;
const city = CITIES[i % CITIES.length]!;
const tags = TAG_POOL[i % TAG_POOL.length]!;
const objects = i % 3 === 0 ? OBJECT_POOL[i % OBJECT_POOL.length]! : [];
// Distribute sources to populate smart albums.
let source: MediaInput['source'] = 'camera';
let name = `IMG_${4000 + i}.jpg`;
let width = 1600;
let height = 1067;
const variant = i % 7;
if (variant === 1) {
source = 'screenshot';
name = `Screenshot 2026-0${(i % 9) + 1}-12 at 9.41.${10 + i}.png`;
width = 1170;
height = 2532;
} else if (variant === 2) {
source = 'download';
name = `download-${i}.jpg`;
} else if (variant === 3) {
source = 'social';
name = `IMG-2026031${i % 9}-WA00${i % 9}.jpg`;
} else if (variant === 5) {
// panorama
width = 2400;
height = 820;
name = `PANO_${i}.jpg`;
}
const isVideo = i % 11 === 4; // a few videos
if (isVideo) {
const v = SAMPLE_VIDEOS[i % SAMPLE_VIDEOS.length]!;
items.push({
src: v,
poster: `https://picsum.photos/seed/${seed}/1280/720`,
thumbnail: `https://picsum.photos/seed/${seed}/600/400`,
name: `MOV_${5000 + i}.mp4`,
kind: 'video',
mime: 'video/mp4',
width: 1280,
height: 720,
duration: 30 + (i % 4) * 12,
takenAt,
source: 'camera',
bytes: 18_000_000 + i * 250_000,
tags,
objectLabels: objects,
favorite: i % 9 === 0,
location: hasLoc ? { ...city } : undefined,
});
continue;
}
items.push({
src: `https://picsum.photos/seed/${seed}/${width}/${height}`,
thumbnail: `https://picsum.photos/seed/${seed}/${Math.round(width / 3)}/${Math.round(height / 3)}`,
name,
kind: 'image',
mime: source === 'screenshot' ? 'image/png' : 'image/jpeg',
width,
height,
takenAt,
source,
bytes: 2_400_000 + i * 90_000,
tags,
objectLabels: objects,
favorite: i % 6 === 0,
isPanorama: variant === 5,
location: hasLoc ? { ...city } : undefined,
exif:
source === 'camera'
? { Make: 'Apple', Model: 'iPhone 16 Pro', FNumber: 1.8, ISO: 100 + (i % 8) * 50 }
: undefined,
});
}
return items;
}
+61
View File
@@ -0,0 +1,61 @@
import { type NextRequest, NextResponse } from 'next/server';
/**
* Per-request Content-Security-Policy with a unique nonce.
*
* `script-src` uses a nonce + 'strict-dynamic' instead of 'unsafe-inline', so an
* injected inline <script> cannot execute even if markup injection occurs.
* Next.js reads the nonce from the request header and applies it to its own
* bootstrap scripts. 'unsafe-eval' is allowed only in development (HMR needs it).
*/
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const isDev = process.env.NODE_ENV === 'development';
const csp = [
"default-src 'self'",
// 'wasm-unsafe-eval' lets in-browser ML (background removal, OCR, etc.) compile WASM.
// cdn.jsdelivr.net = defense for tesseract.js' worker bootstrap (the load-bearing
// path is the blob: worker + connect-src fetch; under 'strict-dynamic' this host is
// ignored, but it covers non-strict-dynamic fallback browsers).
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval' https://cdn.jsdelivr.net${isDev ? " 'unsafe-eval'" : ''}`,
// Leaflet & Framer Motion inject inline styles; keep style inline allowed.
"style-src 'self' 'unsafe-inline'",
// *.supabase.co serves uploaded media from Supabase Storage public URLs.
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://server.arcgisonline.com https://picsum.photos https://*.picsum.photos https://fastly.picsum.photos https://*.supabase.co",
"media-src 'self' data: blob: https://*.supabase.co",
"font-src 'self' data:",
// blob: = in-browser ML workers fetch WASM/model from blob URLs; storage.googleapis.com = TF.js weights;
// *.supabase.co = backend; staticimgly.com = background-removal model;
// cdn.jsdelivr.net = face-api models + tesseract.js worker/WASM-core/traineddata (OCR) + onnxruntime WASM;
// huggingface.co + *.hf.co = transformers.js CLIP model config + weights, which
// redirect to HF's Xet CDN (e.g. us.aws.cdn.hf.co) — semantic search.
"connect-src 'self' blob: data: https://*.tile.openstreetmap.org https://server.arcgisonline.com https://picsum.photos https://fastly.picsum.photos https://storage.googleapis.com https://*.supabase.co https://staticimgly.com https://cdn.jsdelivr.net https://huggingface.co https://*.huggingface.co https://*.hf.co",
"worker-src 'self' blob:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
'upgrade-insecure-requests',
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
// Next reads this request header to nonce its framework scripts.
requestHeaders.set('content-security-policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('content-security-policy', csp);
return response;
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico|icon.svg).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
+26
View File
@@ -0,0 +1,26 @@
import type { Config } from 'tailwindcss';
// Tailwind is used for the demo's own landing page chrome only.
// The SDK ships its own self-contained stylesheet (no Tailwind required).
const config: Config = {
content: ['./src/**/*.{ts,tsx,mdx}'],
theme: {
extend: {
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'SF Pro Text',
'SF Pro Display',
'Inter',
'Segoe UI',
'system-ui',
'sans-serif',
],
},
},
},
plugins: [],
};
export default config;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"module": "esnext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+126
View File
@@ -0,0 +1,126 @@
# AI setup — free & local (no paid Gemini required)
The gallery's AI splits into two groups. **Group 1 already runs for free, on-device, with no API key.**
Only **Group 2** (generative pixel editing) ever needed Gemini — and that backend is now swappable to
a free or fully-local model.
---
## Group 1 — Analysis & background removal — FREE, LOCAL, no key ✅
These run entirely in the user's browser (models download once, then cached). Nothing is sent to any
server; no key or config is required. This is the bulk of "AI" in the app:
| Capability | Runs on | Library |
|---|---|---|
| Object detection → tags, Objects browser, auto object albums | in-browser (WebGL/WASM) | TensorFlow.js COCO-SSD |
| Face detection + recognition → People clustering | in-browser | face-api.js |
| OCR (text in images) → search + Documents album | in-browser | tesseract.js |
| Semantic search / captions embeddings | in-browser | transformers.js (CLIP) |
| **Remove Background** (editor) | in-browser (WASM) | @imgly/background-removal |
So imported photos get the **same analysis as captured ones**, and Remove Background works, **without
Gemini or any key**. If you deploy with no AI keys at all, everything here still works.
---
## Group 2 — Generative pixel editing — pick a free or local backend
These edits *generate new pixels* from a prompt: **Restore, Colorize, Replace Sky, and free-form
Prompt**. Pick a backend with the `AI_EDIT_PROVIDER` env var (default `auto`). The server route
(`/api/ai/edit`) proxies to it so no key ever reaches the browser.
`auto` chooses the first configured of: **local → huggingface → gemini**.
### Option 1 — Your own local Stable Diffusion (free + private, recommended) 🏆
Run Stable Diffusion locally with any of **AUTOMATIC1111 WebUI**, **Forge**, or **SD.Next** — they all
expose the same `img2img` HTTP API. Start it with the API enabled:
```bash
# AUTOMATIC1111 example
./webui.sh --api --listen # serves http://127.0.0.1:7860
```
Then set on the app server:
```env
AI_EDIT_PROVIDER=local
LOCAL_SD_URL=http://127.0.0.1:7860
# optional tuning:
LOCAL_SD_DENOISE=0.55 # 0=keep original … 1=fully reimagine
LOCAL_SD_STEPS=25
LOCAL_SD_SAMPLER=Euler a
```
100% free, unlimited, private (nothing leaves your machine), and works for every generative op. Needs
a machine with a GPU (or a slow CPU). This is the best "own model, everything free" path.
> Deploying to Vercel? A serverless function can't reach your `localhost`. Either run the whole app on
> the same box as SD (Option B in [DEPLOY-MANUAL.md](./DEPLOY-MANUAL.md)), or expose your SD box over a
> tunnel (e.g. `cloudflared`, `ngrok`) and point `LOCAL_SD_URL` at the public URL.
### Option 2 — Hugging Face Inference API (free hosted, zero GPU)
Free token, no GPU needed. Uses an instruction image-editing model.
1. Create a free token at <https://huggingface.co/settings/tokens> (read scope is fine).
2. Set:
```env
AI_EDIT_PROVIDER=huggingface
HF_API_TOKEN=hf_xxxxxxxx
HF_IMAGE_MODEL=timbrooks/instruct-pix2pix # optional; instruction-based image edit
```
Caveats of the free tier: the first call may return **503 "model loading"** (retry in ~20s), and
there are rate limits. Great for a demo; for heavy use prefer Option 1. If a model becomes gated, pick
another image-to-image model with `HF_IMAGE_MODEL`.
### Option 3 — Google Gemini (needs a *billed* key for images)
The **free** Gemini tier only returns text — image output (`gemini-2.5-flash-image` / "Nano Banana")
requires billing enabled, which is why it fails for you. If you enable billing:
```env
AI_EDIT_PROVIDER=gemini
GEMINI_API_KEY=...
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image # optional
```
### Option 4 — none
Leave all three unset (or `AI_EDIT_PROVIDER=none`). Generative edits show a friendly "not configured"
message; **all of Group 1 (analysis + Remove Background) still works**, plus every non-AI editor tool
(crop, rotate, filters, adjust, annotations, and the whole video editor).
---
## Bring your own provider (SDK-level)
The SDK takes a pluggable `AIProvider` (`ai` prop on `<PhotoGallery>`). Every method is optional:
```ts
interface AIProvider {
detectObjects?(item, image): DetectedObject[] | Promise<…>;
detectFaces?(item, image): DetectedFace[] | Promise<…>;
ocr?(item, image): string | Promise<string>;
embedImage?(item, image): number[] | Promise<number[]>;
embedText?(text): number[] | Promise<number[]>;
generativeEdit?(item, image, op): Blob | Promise<Blob>; // return the edited image
}
```
The demo's provider (`apps/web/src/lib/ai/createDemoAIProvider.ts`) wires the in-browser models for
analysis and background-removal, and routes the other generative ops to `/api/ai/edit`. To use a
different service (local Ollama+vision, ComfyUI, Replicate, your own model server, etc.), implement
`generativeEdit` (and/or the analysis methods) and pass your provider — no changes elsewhere.
---
## Summary
| Task | Free? | How |
|---|---|---|
| Object/face/OCR/embedding analysis | ✅ always free | in-browser, no key |
| Remove Background | ✅ always free | in-browser (@imgly) |
| Filters / crop / rotate / adjust / annotate / **video editor** | ✅ always free | no model at all |
| Restore / Colorize / Replace-Sky / Prompt edit | ✅ free via **local SD** or **HF free token** | `AI_EDIT_PROVIDER` |
+49
View File
@@ -0,0 +1,49 @@
# Architecture
## Monorepo
```
packages/photo-sdk/ # the product (React + TS, self-contained CSS, no ML deps)
src/
components/ # PhotoGallery, AppShell, Sidebar, TopToolbar, views/, editor/, Lightbox,
# MediaGrid, PhotoTile, ContextMenu, Modal, SelectionBar, AIAnalyzer, AnnotationLayer
store/ # Zustand store (createGalleryStore) + selectors + React context
adapters/ # StorageAdapter interface + localStorage default (Supabase planned)
ai/ # AIProvider interface (detect/faces/ocr/caption/embed/generativeEdit) + helpers
lib/ # classify, smartAlbums, grouping, edits, media (import/EXIF), format, download
icons/ # original SF-style SVG set
styles/sdk.css # design tokens (light/dark/semi-dark) + components, prefixed .apg-
apps/web/ # Next.js demo
src/app/ # landing, /gallery, /api/ai/edit (Gemini proxy), layout, middleware (nonce CSP)
src/lib/ai/ # tensorflowProvider (coco-ssd), createDemoAIProvider (+ Gemini generativeEdit)
src/lib/seed.ts # demo dataset (Picsum images + local video + synthetic metadata)
```
## Data flow
`<PhotoGallery>` → creates a per-instance Zustand store → `GalleryProvider` (context) → `AppShell`
Sidebar + Toolbar + `ViewRouter`. The store holds media/albums/people/selection/view/theme/AI status.
A pluggable `StorageAdapter` loads/saves state (debounced). Inputs are normalized + sanitized through
`normalizeMediaItem` (URL-scheme allow-list, string coercion) on both the `photos` prop and adapter load.
## Adapters (storage) — all optional
`StorageAdapter = { name, load(), save(state), putBlob?(id, blob), clear?() }`.
- Default: `createLocalStorageAdapter()` (metadata in localStorage, blobs in IndexedDB).
- Planned: Supabase (Postgres + Storage), S3/R2/GCS/Azure/MinIO, REST/GraphQL. Swap via the `adapter` prop.
## AI providers — all optional, free unless noted
`AIProvider` methods (each optional): `detectObjects`, `detectFaces`, `caption`, `ocr`, `embedImage`,
`embedText`, `generativeEdit`. The SDK ships **no ML deps**; providers are injected by the host:
- `tensorflowProvider` (COCO-SSD) — free, in-browser object detection.
- `createDemoAIProvider` — combines detection + Gemini `generativeEdit` (proxied through `/api/ai/edit`).
- Planned: face-api.js (faces), tesseract.js (OCR), transformers.js / Hugging Face (semantic search, captions).
`AIAnalyzer` (headless) runs detection across the library and writes `objects`/`objectLabels` back.
## Theming
CSS variables on `.apg[data-theme]`. Themes: `light`, `dark`, `semi-dark` (glass sidebar + light content).
Customizable via props → CSS vars: `--apg-accent`, `--apg-radius*`. All feature toggles via `features`.
## Security
Per-request **nonce CSP** in `apps/web/src/middleware.ts` (`script-src 'self' 'nonce-…' 'strict-dynamic'`,
no `unsafe-inline`), plus X-Frame-Options/nosniff/Referrer/Permissions-Policy. API keys are server-side
only (read in route handlers; never `NEXT_PUBLIC_`). Imports are type/scheme validated.
See [`ROADMAP.md`](ROADMAP.md) for status of every capability.
+139
View File
@@ -0,0 +1,139 @@
# Manual deployment (no Git) — the whole project (SDK + demo)
Use this when you want to deploy **without pushing to GitHub** — uploading the project straight from
your machine. The monorepo ships the SDK (`packages/photo-sdk`) as TypeScript **source** that the demo
compiles at build time (via Next's `transpilePackages`), so "the whole project" is just: build the
demo, which pulls in the SDK automatically. You do **not** publish the SDK to npm.
Three options, easiest first. **Option A (Vercel CLI) is recommended** — it's a genuine manual upload
(no Git), and it correctly runs this app's server features (per-request nonce CSP, dynamic rendering,
the `/api/ai/edit` route).
---
## Prerequisites (all options)
```bash
pnpm install # once, at the repo root — links the SDK workspace
pnpm build # sanity check: SDK (tsup) + Next production build both succeed
```
Set up Supabase first (tables + the public `media` bucket) — see [`docs/DEPLOY.md`](./DEPLOY.md) §1.
Have your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` ready.
---
## Option A — Vercel CLI (manual upload, no Git) ✅ recommended
Uploads your local files directly to Vercel. No repository required.
```bash
# 1. Install the CLI and sign in
npm i -g vercel
vercel login
# 2. From the REPO ROOT (so the SDK workspace + lockfile are included):
cd d:/advance-photo-gallery-web-sdk
vercel link # create/link a project (first time only)
```
When `vercel link` / the first `vercel` run prompts:
- **Set up and deploy?** → Yes
- **Which scope?** → your account
- **Link to existing project?** → No → give it a name
- **In which directory is your code located?**`./`
- It detects Next.js. If it asks for **Root Directory**, set **`apps/web`**.
(Or set it later: Vercel dashboard → Project → Settings → **Root Directory = `apps/web`**.)
```bash
# 3. Add environment variables (or paste them in the dashboard → Settings → Env Vars)
vercel env add NEXT_PUBLIC_SUPABASE_URL # paste your Project URL, choose Production+Preview
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY # paste your anon key
# optional: NEXT_PUBLIC_SUPABASE_BUCKET (default "media"), and any AI keys (see docs/AI-SETUP.md)
# 4. Deploy to production
vercel --prod
```
Vercel prints a live URL. Re-run `vercel --prod` anytime to redeploy the current local code — still no
Git involved. Uploads honor `.gitignore`/`.vercelignore`, so `node_modules`, `.next`, `.env.local`
are not uploaded (Vercel installs + builds on its side).
> Netlify equivalent: `npm i -g netlify-cli`, `netlify deploy --build --prod` with the
> `@netlify/plugin-nextjs` runtime and Base directory `apps/web`.
---
## Option B — Self-host on a Node server (manual upload / any Node host)
For a VPS, Render, Railway, Fly.io, or your own box. This runs `next start` (a Node server), which
fully supports the SSR + CSP middleware.
```bash
# On your machine:
pnpm install
pnpm build # builds the SDK + apps/web/.next
```
Then run the demo as a Node process on the host:
```bash
# The app must run from apps/web with production env vars set:
cd apps/web
NEXT_PUBLIC_SUPABASE_URL=... NEXT_PUBLIC_SUPABASE_ANON_KEY=... pnpm start # = next start -p 3000
```
**What to upload** to the host (if copying files manually rather than building on the host): the whole
repo, then run `pnpm install && pnpm build && pnpm --filter web start` there. The simplest reliable
recipe is "clone/copy the repo → `pnpm install``pnpm build``pnpm --filter web start`", because
`.next` and `node_modules` are environment-specific and shouldn't be copied between machines.
- **Render / Railway:** create a **Web Service**, Root = repo, Build = `pnpm install && pnpm build`,
Start = `pnpm --filter web start`, add the env vars. (Both have a free tier; some CLIs also allow
direct upload without Git.)
- **Port:** hosts set `$PORT`; use `next start -p $PORT` if required.
---
## Option C — Static export to ANY static host (drag-and-drop) ⚠️ trade-off
If you want to drop a plain folder onto cheap static hosting (Netlify Drop, GitHub Pages, cPanel,
S3…), the app must be exported as static HTML. **This app can't be exported as-is** because it uses a
**per-request nonce Content-Security-Policy** (middleware + dynamic rendering), which requires a
server. To static-export you must **disable that nonce CSP** — a real security downgrade. Only do this
for a throwaway/offline demo, and prefer Option A/B for anything real.
If you accept the trade-off:
1. `apps/web/next.config.mjs` → add `output: 'export'` and `images: { unoptimized: true }`.
2. Remove the dynamic-nonce coupling: delete/neutralize `apps/web/src/middleware.ts` and remove the
`headers()`/nonce usage in `apps/web/src/app/layout.tsx` (static export can't read per-request
headers). Optionally add a **static** CSP via `<meta http-equiv="Content-Security-Policy">` instead
— weaker than the nonce CSP.
3. The Gemini/AI **API route won't exist** in a static export (`/api/ai/edit` is a server route). The
free **in-browser** AI (object detection, faces, OCR, background-removal) still works; hosted
generative edits do not. See [`docs/AI-SETUP.md`](./AI-SETUP.md) for a fully client-side/local AI
setup that needs no server route.
4. Build the static site:
```bash
pnpm build
# output: apps/web/out ← upload THIS folder to any static host / drag-drop
```
5. Upload `apps/web/out/` to your host.
> Because Supabase is reached directly from the browser (anon key + RLS), persistence still works from
> a static host — you only lose the server-side AI route and the strict nonce CSP.
---
## Which should I use?
| Need | Use |
|---|---|
| Fastest real deploy, no Git, keeps security + AI route | **A — Vercel CLI** |
| Your own server / full control | **B — Self-host Node** |
| Must drag a folder to cheap static hosting | **C — Static export** (weaker CSP, no server AI) |
For all options, everything the app stores (photos, videos, tags, versions, comments, albums, edited
copies) persists to **your** Supabase project — see [`docs/DEPLOY.md`](./DEPLOY.md) for the data model
and the required SQL.
+136
View File
@@ -0,0 +1,136 @@
# Deploy everything FREE — step by step (fresh DB + storage + Vercel free domain)
Follow top to bottom. ~15 minutes. All free. No Git required.
**Two things to know first:**
- A **local Stable Diffusion model CANNOT run on Vercel** (no GPU on the free tier). On Vercel, your
free AI is: object detection / faces / OCR / **Remove Background** (all run in the visitor's browser,
no key) + optionally **Hugging Face** (free token) for generative edits. See Step 6.
- Vercel has no "drag a folder from File Explorer" upload for this app (it's a server app). The
no-Git way to upload your local files is the **Vercel CLI** (Step 4). It's 4 commands.
---
## Step 1 — Create a FRESH Supabase project (database + storage)
1. Go to <https://supabase.com> → **Start your project** → sign in (GitHub or email).
2. **New project**: pick a name (e.g. `photo-gallery`), a **region near you**, set a **database
password** (save it somewhere), plan = **Free**. Click **Create**. Wait ~2 minutes.
## Step 2 — Create the tables + storage bucket (one SQL paste)
1. In the project, left sidebar → **SQL Editor****New query**.
2. Open the file `docs/supabase-setup.sql` from this project, **copy ALL of it**, paste into the query
box, and click **Run**. You should see "Success."
- This creates the metadata tables, the demo access policies, **and the public `media` storage
bucket** (the bucket is what makes uploaded/edited/captured files survive a reload).
3. Verify: left sidebar → **Storage** → you should see a bucket named **`media`** marked **Public**.
## Step 3 — Copy your Supabase keys
Left sidebar → **Project Settings** (gear) → **API**. Copy these two (keep the tab open):
- **Project URL** → e.g. `https://abcd1234.supabase.co`
- **Project API keys → `anon` `public`** → a long string
(Never use the `service_role` key in the app — only the `anon` key belongs in the browser.)
## Step 4 — Deploy to Vercel with the CLI (uploads your local files, no Git)
Open a terminal (PowerShell) **in the project folder** `d:\advance-photo-gallery-web-sdk`.
```powershell
# 4a. Sanity build (optional but recommended)
pnpm install
pnpm build
# 4b. Install the Vercel CLI and log in (free account)
npm i -g vercel # if this errors on permissions, use: npx vercel (in place of "vercel" below)
vercel login # opens the browser to sign in
# 4c. Deploy — run this from the REPO ROOT (d:\advance-photo-gallery-web-sdk)
vercel
```
Answer the `vercel` prompts:
| Prompt | Answer |
|---|---|
| Set up and deploy "…"? | **Y** |
| Which scope? | your account |
| Link to existing project? | **N** |
| What's your project's name? | e.g. `photo-gallery` |
| In which directory is your code located? | **`apps/web`** ← important |
| Auto-detected settings (Next.js) — override? | **N** |
It builds and gives you a **Preview URL**. (Monorepo note: pointing the directory at `apps/web` makes
Vercel auto-install the whole pnpm workspace, including the SDK — nothing extra to do.)
## Step 5 — Add your keys, then deploy to production
1. Go to <https://vercel.com> → your project → **Settings → Environment Variables**. Add (for
**Production** and **Preview**):
| Name | Value |
|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | your Project URL from Step 3 |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | your anon public key from Step 3 |
| `NEXT_PUBLIC_SUPABASE_BUCKET` | `media` |
2. Back in the terminal, deploy to production:
```powershell
vercel --prod
```
3. Vercel prints your **free domain**: `https://<your-project>.vercel.app`. That's your live site.
(Rename it under **Settings → Domains** if you like — still free.)
> To redeploy later after changing code: just run `vercel --prod` again. No Git, ever.
## Step 6 — (Optional) Free generative-edit AI on Vercel
Analysis + Remove Background already work with **no key**. For the generative edits (Restore /
Colorize / Replace-Sky / Prompt) on Vercel, add a **free Hugging Face** token:
1. <https://huggingface.co/settings/tokens> → **New token** (read scope) → copy it.
2. Vercel → Settings → Environment Variables, add:
| Name | Value |
|---|---|
| `AI_EDIT_PROVIDER` | `huggingface` |
| `HF_API_TOKEN` | `hf_...` (your token) |
3. `vercel --prod` again.
(First use may say "model loading — try again in ~20s"; that's the free tier warming up.)
**Prefer your own local Stable Diffusion?** It can't live on Vercel — run it on your PC and expose it
with a tunnel (`cloudflared`/`ngrok`), then set `LOCAL_SD_URL` to the tunnel URL. Full details:
[`docs/AI-SETUP.md`](./AI-SETUP.md). If you add nothing, generative edits just show a "not configured"
note and everything else works.
## Step 7 — Verify
Open your `*.vercel.app` URL and check:
- The gallery loads (0 errors).
- Import a photo (Add photos → Choose files) → **hard-reload** → it's still there (it saved to
Supabase). Storage → the `media` bucket now has files; Table editor → `gallery_media` has rows.
- Edit a photo twice → Info panel shows **Version 3**; comments + versions survive reload.
---
## If a deploy fails
- **"Cannot find module @photo-gallery/sdk"** → the Root Directory wasn't set to `apps/web`. Fix:
Vercel → Settings → **Build & Deployment → Root Directory = `apps/web`** (and ensure "Include files
outside the root directory" is on — Vercel usually auto-enables it for pnpm workspaces). Redeploy.
- **Build can't find pnpm** → Vercel → Settings → General → set install to `pnpm install` (it detects
`pnpm-lock.yaml` automatically if present at the repo root — make sure that file exists).
- **Images vanish after reload** → the Supabase env vars aren't set (Step 5), or Step 2's SQL wasn't
run so the `media` bucket is missing.
- **Gemini image edit fails** → the free Gemini tier is text-only; switch to `AI_EDIT_PROVIDER=huggingface`
(Step 6) or run local SD.
---
## Want a literal drag-and-drop (File Explorer) upload instead?
That only works for a **static** build, which needs code changes (disabling the per-request security
CSP) and loses the server AI route — see **Option C** in [`docs/DEPLOY-MANUAL.md`](./DEPLOY-MANUAL.md).
The Vercel CLI above is strongly recommended instead: it uploads your files without Git and keeps the
app fully working (persistence, security, AI route).
+105
View File
@@ -0,0 +1,105 @@
# Deploying for free (Vercel + Supabase free tier)
This app runs 100% on free tiers: **Vercel Hobby** (Next.js hosting) + **Supabase Free**
(Postgres for metadata + Storage for the media files). No paid services are required.
The in-browser AI (object detection, faces, OCR) runs on the user's device for free; only the
optional Gemini generative edit needs a key (and can be left off).
> TL;DR — run the SQL once, push to GitHub, import into Vercel with **Root Directory = `apps/web`**,
> set the two `NEXT_PUBLIC_SUPABASE_*` env vars, deploy. Everything (photos, videos, tags, versions,
> comments, albums, edited copies) then persists to your Supabase project.
---
## 1. Supabase (database + file storage)
1. Create a project at <https://supabase.com> (free tier).
2. Open **SQL Editor → New query**, paste all of [`docs/supabase-setup.sql`](./supabase-setup.sql),
and **Run**. This creates the metadata tables, the demo RLS policies, **and the public `media`
storage bucket** (the bucket is what makes uploaded/edited/captured files survive a reload —
without it the app silently falls back to non-durable URLs).
3. Confirm **Storage** now lists a bucket named `media` marked **Public**.
4. **Project Settings → API** → copy:
- **Project URL**`NEXT_PUBLIC_SUPABASE_URL`
- **anon public** key → `NEXT_PUBLIC_SUPABASE_ANON_KEY`
(Never use the `service_role` key in the client — it stays server-side only, if used at all.)
> Free-tier note: a Supabase project **pauses after ~7 days idle**. Open the dashboard to unpause
> before a demo. Storage free tier is 1 GB, Postgres 500 MB — plenty for a demo library.
## 2. Push the repo to GitHub
Commit everything **including `pnpm-lock.yaml`** at the repo root (Vercel uses it to detect pnpm and
install the whole workspace). Do not commit `.env.local` (it's gitignored).
## 3. Vercel
1. <https://vercel.com> → **Add New → Project** → import your GitHub repo.
2. **Configure the project:**
| Setting | Value |
|---|---|
| **Root Directory** | `apps/web` |
| **Framework Preset** | Next.js (auto-detected) |
| **Install Command** | `pnpm install` (default) |
| **Build Command** | `next build` (default) |
| **Output** | `.next` (default) |
| **Node.js Version** | 20.x |
> No separate SDK build step is needed: `@photo-gallery/sdk` is consumed from TypeScript source
> via Next's `transpilePackages`, so `pnpm install` (which links the workspace) is enough.
3. **Environment Variables** (Project → Settings → Environment Variables), for Production + Preview:
**Client (safe to expose — required for persistence):**
| Name | Value |
|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | your Project URL |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | your anon public key |
| `NEXT_PUBLIC_SUPABASE_BUCKET` | `media` *(optional; default is `media`)* |
**Server-only (optional — do NOT prefix with `NEXT_PUBLIC`):**
| Name | Value |
|---|---|
| `GEMINI_API_KEY` | a Gemini key *(only for the AI generative-edit button)* |
| `GEMINI_IMAGE_MODEL` | `gemini-2.5-flash-image` *(optional)* |
Without the Supabase vars the app still runs, but on browser-local storage only (data won't sync
across devices). Without `GEMINI_API_KEY` the generative-edit route returns 503 while all the free
in-browser AI keeps working.
Optional theming/feature flags (`NEXT_PUBLIC_APG_*`) are documented in [`docs/ENV.md`](./ENV.md).
4. **Deploy.**
## 4. Verify after the first deploy
- Response headers include a per-request `content-security-policy` with a `nonce-…` (confirms the
nonce CSP middleware works on Vercel — expected; the app renders dynamically, not as a static export).
- Import a photo → its `src` becomes a `https://<project>.supabase.co/...` URL → **hard-reload**: it
still shows. Same for a camera capture and an edited copy.
- Edit a photo twice → the Info panel shows **Version 3**; reload → history + comments persist.
- The **Objects** sidebar section fills in as on-device detection tags photos.
## What persists to the database
Everything is stored as JSONB per item (`gallery_media.data`) + binaries in Storage, so a reload
restores it all: **tags, detected objects/labels, faces, versions[] (full edit history + audit log),
comments[], albums, favorites, edits, and Save-as-Copy items** (each copy is a new row with its own
2-entry history). System/smart albums (incl. the auto **object albums**) are regenerated on load, so
they're intentionally not stored.
## Gotchas / limits (free tier)
- **Gemini AI edit + Vercel body limit:** Vercel Hobby caps request bodies at ~4.5 MB. The AI-edit
route is capped accordingly and large images are downscaled client-side first; very large images
may still be rejected. The free in-browser AI is unaffected.
- **Single-user demo:** the Supabase adapter does a full-state sync (no auth). For multi-user, add
Supabase Auth and scope rows per user before going to production (the RLS policies are open for the
demo — tighten them with `auth.uid()`).
- **In-browser video export** (trim/overlays/watermark/etc.) requires the source video to be readable
for canvas capture. Same-origin (Supabase Storage with CORS, which is on by default) works; a
cross-origin host without CORS headers can't be exported in-browser (the editor now surfaces a clear
error instead of failing silently).
- **Heavy AI libs** (TensorFlow.js / face-api / tesseract / transformers / imgly) are dynamically
imported on the client only — they don't bloat the server bundle, but first analysis downloads model
weights (cached afterwards).
+83
View File
@@ -0,0 +1,83 @@
# Environment variables — customizing the gallery
The demo (`apps/web`) reads `NEXT_PUBLIC_APG_*` variables at build time and passes them
to `<PhotoGallery>`. **Everything is optional** — anything you don't set falls back to the
SDK's built-in defaults (the macOSstyle light / dark / semidark themes).
Put these in `apps/web/.env.local` (gitignored) and restart `pnpm dev`.
> Because Next.js only inlines `NEXT_PUBLIC_*` variables that are referenced statically, the
> demo reads each one explicitly in [`apps/web/src/lib/galleryConfig.ts`](../apps/web/src/lib/galleryConfig.ts).
> If you add a new token, add it there too.
---
## Feature flags — show/hide any capability
Each accepts `true` / `false` (or `1` / `0`). Unset = enabled (SDK default).
| Variable | Controls |
| --- | --- |
| `NEXT_PUBLIC_APG_EDITOR` | Photo + video editor |
| `NEXT_PUBLIC_APG_CAMERA` | Custom camera (capture / record) |
| `NEXT_PUBLIC_APG_AI` | In-browser AI (objects / faces / OCR / semantic) |
| `NEXT_PUBLIC_APG_MAP` | Map view |
| `NEXT_PUBLIC_APG_IMPORT` | Upload / import |
| `NEXT_PUBLIC_APG_EXPORT` | Download / export |
| `NEXT_PUBLIC_APG_SHARING` | Share links + Shared Albums + Activity |
```bash
# e.g. ship a viewer-only gallery (no editing, no camera, no uploads)
NEXT_PUBLIC_APG_EDITOR=false
NEXT_PUBLIC_APG_CAMERA=false
NEXT_PUBLIC_APG_IMPORT=false
```
## Theme & appearance
| Variable | Type | Maps to |
| --- | --- | --- |
| `NEXT_PUBLIC_APG_THEME` | `system` \| `light` \| `dark` \| `semi-dark` | Initial theme |
| `NEXT_PUBLIC_APG_ACCENT` | color | Accent (buttons, highlights, focus) |
| `NEXT_PUBLIC_APG_RADIUS` | px number | Base corner radius for all rounded UI |
| `NEXT_PUBLIC_APG_SIDEBAR_RADIUS` | px number | Sidebar panel corner radius |
| `NEXT_PUBLIC_APG_BG_LIGHT` / `_DARK` | color **or gradient** | App / content background |
| `NEXT_PUBLIC_APG_ELEVATED_LIGHT` / `_DARK` | color | Cards / raised surfaces |
| `NEXT_PUBLIC_APG_SIDEBAR_BG_LIGHT` / `_DARK` | color (rgba for opacity) | Sidebar glass backdrop |
| `NEXT_PUBLIC_APG_TEXT_LIGHT` / `_DARK` | color | Primary text |
- **Gradients**: any `_BG_*` value may be a full CSS gradient, e.g.
`linear-gradient(160deg,#1a1a2e,#16213e)`.
- **Opacity**: use `rgba(...)` (or `#rrggbbaa`) for translucency, e.g. a more/less see-through
sidebar: `NEXT_PUBLIC_APG_SIDEBAR_BG_DARK=rgba(20,20,22,0.55)`.
- Light values apply in **light** + **semidark** (content); dark values apply in **dark** mode
and to the **semidark sidebar**.
### Example — branded dark gallery with a gradient background
```bash
NEXT_PUBLIC_APG_THEME=dark
NEXT_PUBLIC_APG_ACCENT=#ff375f
NEXT_PUBLIC_APG_RADIUS=14
NEXT_PUBLIC_APG_SIDEBAR_RADIUS=18
NEXT_PUBLIC_APG_BG_DARK=linear-gradient(165deg,#141018,#241426)
NEXT_PUBLIC_APG_SIDEBAR_BG_DARK=rgba(30,20,38,0.6)
NEXT_PUBLIC_APG_ELEVATED_DARK=#2a2030
```
## Using the tokens directly (without env)
If you embed the SDK yourself, pass the same values as props — no env needed:
```tsx
<PhotoGallery
theme="dark"
accentColor="#ff375f"
themeTokens={{
bgDark: 'linear-gradient(165deg,#141018,#241426)',
sidebarBgDark: 'rgba(30,20,38,0.6)',
sidebarRadius: 18,
}}
features={{ camera: false }}
/>
```
+179
View File
@@ -0,0 +1,179 @@
# ROADMAP — full feature checklist
Living source of truth for what's **done ✅**, **in progress 🚧**, and **planned ⬜**. Every feature is
designed to be **optional via flags** (`features={{ … }}`) — a consuming project enables only what it wants.
> Update this file as work lands. It is the authoritative cross-developer / cross-AI record.
## Legend
✅ done & verified · 🚧 in progress · ⬜ planned · 🔑 needs an API key/paid plan
---
## 1. UI & theming
- ✅ macOS Photos shell: sidebar, top toolbar, content area, window chrome (optional)
- ✅ Sidebar: Library, Collections, Pinned (Favourites/Recently Saved/Map/Videos/Screenshots/People & Pets/
Recently Deleted w/ lock icon), Albums, Sharing (Shared Albums/Activity), Utilities, Projects
- ✅ Light mode · ✅ Dark mode · ✅ **Semi-dark** (glass dark sidebar + light content)
- ✅ **macOS vibrancy/"glass"**: translucent backdrop-blur menus, sidebar, toolbar, Info panel & search dropdown
(theme-aware fills + hairline borders + `--apg-radius-menu`); `@supports` fallback to a solid fill where
`backdrop-filter` is unavailable
- ✅ Responsive desktop/laptop/tablet/mobile + ultra-narrow (Galaxy Fold ~280px); mobile drawer sidebar
- ✅ Theme switch control in UI (Light/Dark/Semi-Dark/System menu) · ⬜ optional **weather-aware** auto theme
- ✅ Theming via props: **accentColor** + **borderRadius** (CSS vars); ⬜ density/font presets
- ✅ Accessibility: focus traps in overlays, ARIA dialog roles, keyboard nav, reduced-motion, contrast (AA)
## 2. Library, viewing & navigation
- ✅ Library views: Years / Months / All Photos (Days grouping supported) — **date/time grouping**
- ✅ Collections (Memories, Pinned, Albums, People, Featured, Shared, Recent Days, Trips, Utilities, Objects)
- ✅ Adaptive responsive grid + zoom in/out, lazy images
- ✅ **Lightbox** photo viewer (prev/next, keyboard, actions) — 🚧 polish to 100% macOS parity
- ✅ **Video player** (native controls in lightbox) — 🚧 macOS-style custom controls/scrubber
- ✅ Multi-select (click **toggles** select/deselect; **Select All ↔ Unselect All**), **Copy to Album** +
**Move to Album** (album views), keyboard shortcuts (⌘A, Delete, F, Esc, Space)
## 3. Albums
- ✅ Create / rename / delete / duplicate; nested folders; covers
- ✅ Add / remove / move items; "Remove from Album"
- ✅ Smart albums (rule engine): Favourites, Videos, Screenshots, RAW, Live, Panoramas, Recently Saved
- ✅ **Object albums** (auto, from detection) via the Objects browser → object focus
- ✅ **People** (auto, from face clustering) — populates the People & Pets view; click → person-focused grid
- ✅ **Documents** smart album (auto, from OCR `hasText` rule) — in sidebar + Collections
- ⬜ "Copy into tag folder" materialized albums (e.g. a real "Table" album) — currently virtual via object focus
## 4. Import & capture
- ✅ Import via **file browse** + **drag-and-drop** (images & videos); accepted-type allow-list
- ✅ Reads dimensions / video duration; source auto-classification (camera/screenshot/download/social/scan/ai)
- ✅ **Real EXIF extraction** (GPS→location, DateTimeOriginal→capture date, Make/Model, lens, ISO) via `exifr`
- ✅ **Custom camera**: capture photo, record video (getUserMedia), front/back switch, rule-of-thirds grid
- ✅ Live annotation while capturing (shapes + measurement arrows; saved with the capture)
- ✅ Detailed metadata: format, **extension**, byte size, dimensions, lat/lon, date/time, source (Info panel)
- ✅ Geolocation detect on capture (Geolocation API, permission-gated, in the custom camera)
- ✅ Object detect on **import AND capture** → auto-tag + object albums (AIAnalyzer runs on new media)
## 5. Map & places
- ✅ Leaflet map, free tiles (OSM + Esri satellite), no API key
- ✅ Map / Satellite / **Grid** modes; markers for located media; markers re-sync on library change
- ✅ **macOS thumbnail pins** (photo cover + count badge + tail), **zoom-aware clustering**; tap a pin → that
location's photos in a **date-grouped sheet**; any GPS item (incl. screenshots) pins automatically
- ✅ **Grid tab = Memories mosaic**: date sections, full-width **auto-sliding hero** + irregular varied tiles
- ✅ **Mini-map** in the photo info panel
- Note: demo seed intentionally locates ~half the photos; real photos populate via EXIF GPS (§4)
## 6. Editor (photo & video)
- ✅ Photo: Adjust (13 sliders), Filters (10 presets), Crop/Rotate/Flip — non-destructive
- ✅ **Annotation layer**: rectangle, ellipse/oval, line, **arrow**, **double-arrow with center text**
(measurement, e.g. "12 cm"), text, freehand — with **color**; in gallery edit + read-only in lightbox
- ✅ Annotation in **custom camera** (live) and on **video** (markup layer in the video editor)
- ✅ **Full video editor** — Adjust, Filters, **Trim**, **Markup** (annotations), **Overlay** (composite an image),
**Audio** (mute original + add a music track w/ volume); exports a new clip via canvas + MediaRecorder + WebAudio
(free, no ffmpeg); **Save** (overwrite) or **Save as Copy** (pick album), Cancel-confirm — mirrors the photo editor
- ✅ Edited result persists (CSS-filter model + Gemini/canvas bakes; video bakes to a new webm)
- ✅ **Save vs Save as Copy** (overwrite the photo, or create a new copy and pick its album) + **Cancel
confirmation** when there are unsaved edits (Escape too)
## 7. AI (all optional; free unless marked 🔑)
- ✅ **Object detection** — TensorFlow.js COCO-SSD, in-browser, no key. Powers tags/search/find-similar/Objects
- ✅ **Generative editor** — Gemini (Remove BG / Restore / Colorize / Replace Sky / prompt). 🔑 needs image-gen
quota (billing); free-tier key returns 429. Key is server-side only via `/api/ai/edit`
- ✅ **Face detection + clustering** → People — `@vladmandic/face-api` (TF.js), in-browser, **no key**.
Detects faces + 128-D descriptors during analysis; the SDK clusters them (`lib/cluster.ts`) into People.
Click a person → library filters to their photos. Models lazy-loaded from jsDelivr (CSP-allow-listed).
- ✅ **OCR** (tesseract.js, in-browser, **no key**) → text extracted per image, **searchable** (search a word
printed inside a photo and it's found), feeds the **Documents** smart album. A per-word confidence filter
rejects the gibberish tesseract emits for photos without text, so only real documents qualify.
- ✅ **Semantic search** "show me snowy mountains" — CLIP (transformers.js, `Xenova/clip-vit-base-patch16`,
in-browser, **no key**); image embeddings computed during analysis, query embedded on search, ranked by cosine
and blended after keyword hits. Models from the HF CDN (allow-listed); ~512-D vectors persist with each item
- ⬜ **Captions** (transformers.js BLIP or HF, free)
- ⬜ Pluggable **Hugging Face** provider option (Inference API / transformers.js)
## 7b. Advanced-features backlog (audited; prioritized — next candidates)
> From a full feature audit vs. macOS Photos. HIGH = most-requested / highest value.
- ✅ **Custom video player** (glass scrubber, buffered/played, volume, PiP, fullscreen, keyboard)
- ⬜ **HIGH — Lightbox zoom/pan** (scroll / double-click / pinch) for detail viewing
- ⬜ **HIGH — Editable metadata** in the Info panel (caption, keywords/tags, title; bulk edit)
- ⬜ **HIGH — Slideshow / Memories playback** (auto-advance + Ken Burns transitions)
- ⬜ MED — Lightbox **filmstrip** of adjacent media; **timeline scrubber** to jump by date
- ⬜ MED — **Keyboard-shortcut help** modal (`?`); album **drag-to-reorder**
- ⬜ MED — **Skeleton loaders** on library load; selection-count bar inside the lightbox
- ⬜ LOW — Smart-album **rule builder** UI; materialized tag albums; **print** dialog; import-from-URL; undo/redo beyond the editor; true windowed virtualization
## 8. Backend & storage (all optional adapters)
- ✅ Default zero-config localStorage + IndexedDB adapter (`StorageAdapter` interface)
- ✅ **Supabase**: Postgres (data) + Supabase Storage (blobs) adapter
(`apps/web/src/lib/adapters/supabaseAdapter.ts`); imports/captures upload blobs via `putBlob`; metadata in
JSONB tables. Wired in the demo when env vars are set; falls back to localStorage otherwise. **Run
`docs/supabase-setup.sql` once** (creates tables + RLS + storage policy), then reload. 🔑 creds in `.env.local`.
- ⬜ Alternate adapters documented as options: S3 / R2 / GCS / Azure / MinIO; Postgres/MySQL/SQLite/Mongo;
Prisma/Drizzle; REST/GraphQL/WebSocket; NestJS/Fastify service; BullMQ/Redis workers; vector DB
(Qdrant/Pinecone/Weaviate/Chroma) for semantic search
## 9. Search
- ✅ Text search (name, place, tag, detected object, source)
- ✅ Click-object → find every photo with it (object focus)
- ✅ **OCR text search** — words printed inside an image are matched by the normal search
- ✅ **Natural-language semantic search** (CLIP) — "looks like" matches blended after keyword hits (§7)
## 10. Cross-cutting
- ✅ Security: nonce CSP, headers, input validation, URL allow-list, server-only keys, sanitization
- ✅ Recycle bin (30-day), duplicate detection + merge, export/download + metadata
- ✅ **Sharing**: Share a photo / multiple / one-or-more albums → generates a copyable link (+ Download);
**Shared Albums** (copy-link / revoke) + **Activity** feed of shares. Client-side share records (localStorage).
- ✅ **Recently Deleted password lock**: set/change/remove a password (Web Crypto SHA-256, device-local), sidebar
lock↔unlock icon, lock screen prompts for the password; settable from the ⋯ menu or the trash header
- ✅ Lazy-load `@supabase/supabase-js` (dynamic import) — /gallery first load trimmed 234 KB → 176 KB
- ⬜ Performance: true windowing/virtualization for very large libraries (currently lazy + auto-fill)
- ✅ Docs: README + `docs/SETUP.md` + `docs/ARCHITECTURE.md` + this roadmap + project-local `CLAUDE.md`
- ✅ Testing: Playwright e2e (manual runs); ⬜ automated CI suite
## Web-meaningful UI (done 2026-06-29)
- ✅ "All Projects / App Store" (iOS-only) → **"All Albums"** overview (My Albums + Create + Recent Days).
- ✅ Sidebar Albums section: All Albums + New Album + user albums (indented).
## New requests
- ✅ **Advanced video editor** (2026-07-07): **multi-segment trim** (keep several ranges, cut middles,
per-segment **speed**), **image/watermark overlays that actually composite** (bug fixed), **text
overlays**, **keyframe animation** (move/fade/scale/rotate over time), **crop + 90° rotate + flips
for video**, **audio** (mute/volume + music + master fade in/out), **poster/thumbnail picker**, and
**export quality** (480p/720p/1080p, fps). Built on a rewritten canvas+MediaRecorder **timeline
engine** (`lib/videoBake.ts` + shared `lib/videoTimeline.ts`); no ffmpeg. Bake failures now surface a
clear error instead of silently dropping edits.
- ✅ **Auto object smart albums** (2026-07-07): one live smart album per detected object (Person, Car,
Chair, Table…) in a new sidebar **Objects** section; Collections "Objects" cards open the album.
Membership is resolved live; the album set refreshes on analysis completion.
- ✅ **Upload / analysis / persistence fixes** (2026-07-07): reliable file-picker ("Choose files"
button); uploaded photos now get the **same AI analysis as captures** (newest-first + drain-loop, no
starvation); versioning **increments correctly and survives reload** (load-path no longer strips
`versions`/`comments`); **Save-as-Copy carries its own history**; imports/captures persist **durable**
URLs (no dead `blob:` after reload); Supabase setup now **creates the public `media` bucket**.
- ✅ **Free deployment guide** — [`docs/DEPLOY.md`](./DEPLOY.md) (Vercel + Supabase free tier) +
[`docs/DEPLOY-MANUAL.md`](./DEPLOY-MANUAL.md) (no-Git manual upload: Vercel CLI / self-host Node / static export).
- ✅ **Swappable AI backend (no paid Gemini)** — [`docs/AI-SETUP.md`](./AI-SETUP.md): analysis
(objects/faces/OCR/embeddings) + Remove Background already run FREE in-browser (no key); generative
edits (`/api/ai/edit`) now pick a backend via `AI_EDIT_PROVIDER` = **local** (own Stable Diffusion —
free+private), **huggingface** (free token), or **gemini**, with an `auto` fallback.
- ✅ **Versioning + Audit Log** (photos **and** videos, 2026-07-02): every save appends a new version (v1 =
original, **never overwritten**); each version carries a human-readable audit log of what changed. Info panel →
**Version history** (per-version thumbnail preview, timestamp, expandable "What changed", **Restore**); sidebar
**Versions & Audit** view browses all edited/commented items. Persisted inline on `MediaItem` → Supabase JSONB.
- ✅ **Comments** (photos **and** videos, 2026-07-02): threaded comments with author + timestamp, add/delete, shown
in the Info panel; persisted with the item. Author remembered locally (`apg:comment-author`).
- ✅ Editor **straighten/tilt** slider + ✅ **interactive crop** (free + fixed-ratio: Square/4:3/3:4/16:9/9:16/Original;
square stays square while dragging) — **bakes on Done** (canvas) and uploads the result to Supabase Storage
- ✅ Search dropdown defaults: **Recently Viewed / Recently Edited / Recently Added** (focus the search box)
- ✅ Import existing **IPTC/XMP keywords** + location on import (exifr iptc+xmp)
- ✅ **Local Remove Background** (`@imgly/background-removal`) — runs fully in-browser (WASM), **no key**; the
editor's Remove BG uses it directly (Gemini not required). Other generative ops still use Gemini.
- ✅ Info mini-map click → opens full **Map centered** on the photo; ✅ **tag click → date-grouped images**
- ✅ Camera annotations now applied to the captured still (saved); ✅ Undo/Clear in camera + editor
- ✅ Geolocation tagged on capture (prefetched, permission-gated); ✅ device info in capture EXIF
- ✅ Auto **Camera** + **Screenshots** albums (created on first capture/import of that source)
- ✅ Recycle bin: 30-day retention enforced (purge on load); restore/permanent-delete sync to Supabase
## Suggested build order (remaining)
1. ✅ Map/Annotation/EXIF/Info/Semi-dark/theming + ✅ Custom camera + ✅ web-meaningful UI (done 2026-06-29)
2. ⬜ **Supabase adapter (Postgres + Storage)** ← next (needs creds; no localStorage reliance after)
3. ⬜ Editor straighten + ratio crop; search defaults; import tags; local remove-bg; map/tag nav
4. ⬜ Face clustering → People; OCR → Documents; semantic search (free, HF/transformers.js)
5. ⬜ Video editor/annotations; macOS-parity viewer/player polish; perf virtualization; CI
+73
View File
@@ -0,0 +1,73 @@
# Developer setup & run guide
## Prerequisites
- **Node ≥ 20** and **pnpm** (`corepack enable` ships pnpm with Node).
- Git Bash / PowerShell on Windows both work; macOS/Linux fine.
## Install & run
```bash
pnpm install # installs all workspaces
pnpm dev # Next.js demo on http://localhost:3000
# / → landing page (has the "Open Gallery" button)
# /gallery → the full gallery (40+ seeded photos/videos)
```
## Build & verify
```bash
pnpm build:sdk # build the publishable SDK → packages/photo-sdk/dist (ESM, CJS, .d.ts, styles.css)
pnpm build # SDK + Next production build
pnpm start # serve the production build
pnpm typecheck # type-check every package
pnpm format # prettier
```
## Environment / secrets
Copy `.env.example``apps/web/.env.local` (gitignored — never commit). Keys are **server-side only**.
```bash
# Generative AI editor (optional). Requires a Gemini key with IMAGE-GENERATION quota (billing enabled);
# a free-tier key authenticates but returns HTTP 429 for image output.
GEMINI_API_KEY=your-key
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
# Supabase backend (optional, planned adapter):
# NEXT_PUBLIC_SUPABASE_URL=...
# SUPABASE_SERVICE_ROLE_KEY=... # server-side only
```
## Using the SDK in your app
```tsx
'use client';
import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
<PhotoGallery
photos={myPhotos}
theme="system" // 'light' | 'dark' | 'semi-dark' | 'system'
accentColor="#0a84ff"
borderRadius={10}
features={{ editor: true, camera: true, ai: true, map: true, import: true, export: true }}
adapter={myStorageAdapter} // optional; default = localStorage
ai={myAIProvider} // optional; object detection / generative edit / faces / OCR / search
/>
```
Mount in a **Client Component** (uses browser APIs). Import `leaflet/dist/leaflet.css` once if using the Map.
## Feature flags
Everything is optional. Pass `features={{ … : false }}` to hide a capability. See
[`ROADMAP.md`](ROADMAP.md) for the full list and [`ARCHITECTURE.md`](ARCHITECTURE.md) for adapters/providers.
## Project memory (important)
This repo carries its own AI/developer memory in the root — **`CLAUDE.md`** + this `docs/` folder — *not*
in any individual machine's `~/.claude`. So if another developer (or a different Claude/AI account) opens
this package, the full context, decisions and roadmap are available. Keep `docs/ROADMAP.md` updated as you
work; treat it as the authoritative status.
## Testing
Playwright (via MCP) is used for e2e checks (navigate, screenshot, console). A scripted CI suite is planned.
## Troubleshooting
- **pnpm build-scripts blocked** (esbuild/sharp/core-js): they're allow-listed in `pnpm-workspace.yaml`
(`allowBuilds`); run `pnpm rebuild esbuild sharp` if needed.
- **Map tiles/photos blank**: check the CSP `connect-src`/`img-src` in `apps/web/src/middleware.ts`.
- **AI edit returns 429**: Gemini image generation needs billing — see Environment above.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
-- Photo Gallery SDK — Supabase setup.
-- Run ONCE in your Supabase project: Dashboard → SQL Editor → New query → paste → Run.
-- Creates the metadata tables + permissive demo RLS policies + anon upload policy for the
-- public `media` storage bucket. NOTE: these policies are open (no auth) for the demo —
-- add Supabase Auth and tighten the policies (e.g. `auth.uid()`) before production.
create table if not exists public.gallery_media (
id text primary key,
data jsonb not null,
taken_at bigint,
deleted_at bigint,
updated_at timestamptz not null default now()
);
create table if not exists public.gallery_albums (
id text primary key,
data jsonb not null,
updated_at timestamptz not null default now()
);
create table if not exists public.gallery_people (
id text primary key,
data jsonb not null,
updated_at timestamptz not null default now()
);
alter table public.gallery_media enable row level security;
alter table public.gallery_albums enable row level security;
alter table public.gallery_people enable row level security;
-- Demo policies: allow the anon role full access. Replace with auth-scoped policies for production.
drop policy if exists "demo_all" on public.gallery_media;
drop policy if exists "demo_all" on public.gallery_albums;
drop policy if exists "demo_all" on public.gallery_people;
create policy "demo_all" on public.gallery_media for all to anon using (true) with check (true);
create policy "demo_all" on public.gallery_albums for all to anon using (true) with check (true);
create policy "demo_all" on public.gallery_people for all to anon using (true) with check (true);
-- Storage: CREATE the public `media` bucket (this is the linchpin — without it every
-- upload fails and images fall back to non-durable blob:/data: URLs). Public = reads
-- work via getPublicUrl. Re-running is safe (idempotent) and re-asserts public + limits.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'media', 'media', true, 209715200,
array['image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp','image/tiff',
'video/mp4','video/quicktime','video/webm','video/x-matroska','video/3gpp']
)
on conflict (id) do update
set public = true,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
-- Allow anon to read/upload/update objects in the `media` bucket.
drop policy if exists "media_read" on storage.objects;
drop policy if exists "media_insert" on storage.objects;
drop policy if exists "media_update" on storage.objects;
create policy "media_read" on storage.objects for select to anon using (bucket_id = 'media');
create policy "media_insert" on storage.objects for insert to anon with check (bucket_id = 'media');
create policy "media_update" on storage.objects for update to anon using (bucket_id = 'media') with check (bucket_id = 'media');
+24
View File
@@ -0,0 +1,24 @@
{
"name": "advance-photo-gallery",
"version": "0.1.0",
"private": true,
"description": "macOS Photos-style web photo gallery, packaged as a reusable SDK + a Next.js demo.",
"packageManager": "pnpm@11.9.0",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "pnpm --filter web dev",
"build:sdk": "pnpm --filter @photo-gallery/sdk build",
"build": "pnpm --filter @photo-gallery/sdk build && pnpm --filter web build",
"start": "pnpm --filter web start",
"typecheck": "pnpm -r --if-present typecheck",
"lint": "pnpm -r --if-present lint",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md,css}\""
},
"devDependencies": {
"@types/node": "^20.16.11",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
}
+25
View File
@@ -0,0 +1,25 @@
# @photo-gallery/sdk
A reusable, macOS Photos-style photo gallery for React / Next.js — light & dark, fully responsive,
pluggable storage and AI. One component, no required CSS framework.
```tsx
import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
export default () => <PhotoGallery photos={myPhotos} theme="system" />;
```
See the [repository README](../../README.md) for full documentation, props, adapters and the
AI-provider interface.
## Build
```bash
pnpm build # → dist/ : index.js (ESM), index.cjs (CJS), index.d.ts, styles.css
```
The package exports source from `src/` for zero-build use inside the monorepo (via
`transpilePackages`), and `dist/` is produced for external publishing.
MIT · Original implementation, not affiliated with Apple.
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@photo-gallery/sdk",
"version": "0.1.0",
"description": "A reusable, macOS Photos-style photo gallery for React / Next.js. The shadcn of photo galleries.",
"license": "MIT",
"type": "module",
"sideEffects": [
"*.css"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles/sdk.css"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup && node -e \"require('fs').copyFileSync('src/styles/sdk.css','dist/styles.css')\"",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@tanstack/react-virtual": "^3.10.8",
"clsx": "^2.1.1",
"exifr": "^7.1.3",
"framer-motion": "^11.11.9",
"leaflet": "^1.9.4",
"nanoid": "^5.0.7",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/leaflet": "^1.9.12",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tsup": "^8.3.0",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,108 @@
import { STORAGE_KEY } from '../constants';
import { normalizeMediaItem } from '../lib/media';
import type { Album, MediaItem } from '../types';
import type { PersistedState, StorageAdapter } from './types';
const CURRENT_VERSION = 1;
/**
* Default zero-config adapter. Persists library metadata to localStorage and
* (optionally) binary blobs to IndexedDB so imported files survive reloads.
*
* Safe under SSR: all browser APIs are guarded with `typeof window` checks.
*/
export function createLocalStorageAdapter(key: string = STORAGE_KEY): StorageAdapter {
const hasWindow = typeof window !== 'undefined';
return {
name: 'localStorage',
async load(): Promise<PersistedState | null> {
if (!hasWindow) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedState;
if (!parsed || typeof parsed !== 'object') return null;
// Field-level validation — never trust persisted data blindly. Every media
// item is re-normalized (URL scheme allow-list, string coercion) so a
// tampered localStorage record cannot inject unsafe values into the UI.
const media: MediaItem[] = Array.isArray(parsed.media)
? parsed.media
.map((m) => normalizeMediaItem(m))
.filter((m): m is MediaItem => m !== null)
: [];
const albums: Album[] = Array.isArray(parsed.albums)
? parsed.albums
.filter((a): a is Album => Boolean(a) && typeof a === 'object')
.map((a) => ({
...a,
name: typeof a.name === 'string' ? a.name : 'Album',
mediaIds: Array.isArray(a.mediaIds)
? a.mediaIds.filter((id): id is string => typeof id === 'string')
: [],
}))
: [];
return {
media,
albums,
people: Array.isArray(parsed.people) ? parsed.people : [],
version: typeof parsed.version === 'number' ? parsed.version : CURRENT_VERSION,
};
} catch {
return null;
}
},
async save(state: PersistedState): Promise<void> {
if (!hasWindow) return;
try {
window.localStorage.setItem(
key,
JSON.stringify({ ...state, version: CURRENT_VERSION }),
);
} catch {
// Quota exceeded or storage disabled — fail silently, app still works in-memory.
}
},
async putBlob(id: string, blob: Blob): Promise<string> {
if (!hasWindow) return '';
const db = await openBlobDb();
if (!db) return URL.createObjectURL(blob);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').put(blob, id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return URL.createObjectURL(blob);
},
async clear(): Promise<void> {
if (!hasWindow) return;
try {
window.localStorage.removeItem(key);
} catch {
/* noop */
}
},
};
}
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openBlobDb(): Promise<IDBDatabase | null> {
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
const req = indexedDB.open('photo-gallery-sdk-blobs', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('blobs')) db.createObjectStore('blobs');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
return dbPromise;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Album, MediaItem, Person } from '../types';
export interface PersistedState {
media: MediaItem[];
albums: Album[];
people: Person[];
version: number;
}
/**
* Storage adapter contract. Implement this to back the gallery with anything:
* localStorage (default), IndexedDB, a REST/GraphQL API, S3, Postgres, etc.
*
* The default UI calls `load` once on mount and `save` (debounced) on change.
* `putBlob` is optional and only used when importing local File objects that
* need durable URLs.
*/
export interface StorageAdapter {
readonly name: string;
load(): Promise<PersistedState | null>;
save(state: PersistedState): Promise<void>;
/** Persist a binary blob and return a stable URL to it. */
putBlob?(id: string, blob: Blob): Promise<string>;
clear?(): Promise<void>;
}
+62
View File
@@ -0,0 +1,62 @@
import type { DetectedFace, DetectedObject, MediaItem } from '../types';
/**
* Pluggable AI provider. The UI never calls a model directly it calls this
* interface, so any backend (free in-browser TensorFlow.js / transformers.js,
* a self-hosted model, or a cloud API like Gemini) can be swapped in.
*
* Every method is optional; features degrade gracefully when a capability is
* absent. Implementations should be lazy (load models on first use).
*/
export interface AIProvider {
readonly name: string;
/** Detect objects (table, chair, laptop, dog, ...). Enables click-to-find. */
detectObjects?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<DetectedObject[]>;
/** Detect (and optionally embed) faces for clustering into People. */
detectFaces?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<DetectedFace[]>;
/** Generate a one-line natural-language caption / description. */
caption?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<string>;
/** Extract printed/handwritten text (OCR) for document search. */
ocr?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<string>;
/** Produce an embedding vector for semantic similarity & NL search. */
embedImage?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<number[]>;
embedText?(query: string): Promise<number[]>;
/** Generative edits (sky replace, magic eraser, generative fill, restore). */
generativeEdit?(
item: MediaItem,
image: ImageBitmap | HTMLImageElement,
op: GenerativeEditOp,
): Promise<Blob>;
}
export type GenerativeEditOp =
| { type: 'remove-background' }
| { type: 'replace-sky'; prompt?: string }
| { type: 'magic-eraser'; mask: ImageData }
| { type: 'generative-fill'; prompt: string; mask: ImageData }
| { type: 'restore' }
| { type: 'upscale'; factor: 2 | 4 }
| { type: 'colorize' }
/** Free-form natural-language edit instruction. */
| { type: 'prompt'; prompt: string };
/** Cosine similarity between two equal-length vectors. */
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return 0;
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i]! * b[i]!;
na += a[i]! * a[i]!;
nb += b[i]! * b[i]!;
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useRef } from 'react';
import type { AIProvider } from '../ai/types';
import { PET_LABELS } from '../constants';
import { sanitizeOcrText } from '../lib/text';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaItem } from '../types';
const CONCURRENCY = 2;
const CONFIDENCE = 0.5;
const START_DELAY_MS = 1200;
/**
* Headless background worker: runs the configured AIProvider across un-analyzed
* images and writes results back into the store objects + objectLabels (powers
* "click an object → find every photo with it" + the Objects browser), faces
* (clustered into People), and OCR text (searchable + the Documents album).
*
* The SDK ships no ML dependency the provider (TensorFlow.js / face-api /
* tesseract.js) is supplied by the host app, so this stays tiny and tree-shakeable.
*/
export function AIAnalyzer({ provider }: { provider: AIProvider }) {
const api = useGalleryStoreApi();
const ready = useGallery((s) => s.ready);
// Re-evaluate only when the library size changes (not on every metadata write).
const mediaCount = useGallery((s) => s.media.length);
const runningRef = useRef(false);
useEffect(() => {
const canDetect =
provider.detectObjects || provider.detectFaces || provider.ocr || provider.embedImage;
if (!ready || !canDetect || runningRef.current) return;
// Each capability has its OWN backfill gate (not the shared analyzedAt), so a
// capability added in a later session re-processes items analyzed before it
// existed: objects key off analyzedAt; faces off `faces === undefined`; OCR
// off `ocrText === undefined`.
const needsObjects = (m: MediaItem) => !!provider.detectObjects && !m.analyzedAt;
const needsFaces = (m: MediaItem) => !!provider.detectFaces && m.faces === undefined;
const needsOcr = (m: MediaItem) => !!provider.ocr && m.ocrText === undefined;
const needsEmbedding = (m: MediaItem) => !!provider.embedImage && m.embedding === undefined;
// Collect items still needing analysis, NEWEST FIRST — so a freshly uploaded or
// captured photo is tagged immediately instead of waiting behind the whole library.
const collectPending = () =>
api
.getState()
.media.filter(
(m) =>
m.kind === 'image' &&
!m.deletedAt &&
m.src &&
(needsObjects(m) || needsFaces(m) || needsOcr(m) || needsEmbedding(m)),
)
.sort((a, b) => (b.importedAt ?? 0) - (a.importedAt ?? 0));
const pending = collectPending();
if (pending.length === 0) {
// Nothing to analyze, but faces may already exist (e.g. loaded from the
// backend) while People is empty — cluster them once so People populates.
const st = api.getState();
const hasFaces = st.media.some((m) => (m.faces?.length ?? 0) > 0);
const hasObjects = st.media.some((m) => m.objectLabels.length > 0);
// Also (re)build if pets are present but not yet grouped — so libraries
// analyzed before pet grouping existed pick them up on next load.
const petsPresent = st.media.some((m) =>
m.objectLabels.some((l) => (PET_LABELS as readonly string[]).includes(l)),
);
const hasPetGroups = st.people.some((p) => p.isPet);
if ((st.people.length === 0 && (hasFaces || hasObjects)) || (petsPresent && !hasPetGroups)) {
st.rebuildPeople();
}
// Ensure object smart albums exist for an already-analyzed library (e.g. loaded
// from the backend), even when there's nothing new to analyze.
if (hasObjects) st.syncObjectAlbums();
return;
}
runningRef.current = true;
let cancelled = false;
let done = 0;
const analyzeOne = async (item: MediaItem) => {
try {
const img = await loadImage(item.src);
// The three capabilities are independent; only run what's missing for this item.
const wantObjects = needsObjects(item);
const wantFaces = needsFaces(item);
const wantOcr = needsOcr(item);
const wantEmbedding = needsEmbedding(item);
const [objects, faces, ocrText, embedding] = await Promise.all([
wantObjects ? (provider.detectObjects!(item, img) ?? []) : Promise.resolve(null),
wantFaces ? (provider.detectFaces!(item, img) ?? []) : Promise.resolve(null),
// OCR + embedding are the slowest/most failure-prone legs — isolate each
// failure so one can't reject the whole Promise.all and abort the others.
wantOcr ? provider.ocr!(item, img).catch(() => '') : Promise.resolve(null),
wantEmbedding ? provider.embedImage!(item, img).catch(() => []) : Promise.resolve(null),
]);
const patch: Partial<MediaItem> = { analyzedAt: Date.now() };
if (objects) {
patch.objects = objects;
patch.objectLabels = [
...new Set(objects.filter((o) => o.confidence >= CONFIDENCE).map((o) => o.label)),
];
}
if (faces) patch.faces = faces;
// Always patch ocrText/embedding (even '' / []) so their `=== undefined`
// gates flip and the item isn't reprocessed forever.
if (ocrText !== null) patch.ocrText = sanitizeOcrText(ocrText);
if (embedding !== null) patch.embedding = embedding;
api.getState().updateMedia(item.id, patch);
} catch {
// Mark analyzed (incl. faces + ocrText + embedding) on failure so a broken image isn't retried forever.
api.getState().updateMedia(item.id, {
analyzedAt: Date.now(),
faces: item.faces ?? [],
ocrText: item.ocrText ?? '',
embedding: item.embedding ?? [],
});
} finally {
done += 1;
api.getState().setAiStatus({ done });
}
};
const run = async () => {
api.getState().setAiStatus({ running: true, done: 0, total: pending.length });
// Drain in rounds: any items imported/captured WHILE a pass runs are picked up
// by the next round, so uploads get analyzed exactly like captures (no starvation,
// nothing left behind). Each round re-scans and prioritizes the newest items.
while (!cancelled) {
const round = collectPending();
if (round.length === 0) break;
api.getState().setAiStatus({ total: done + round.length });
const queue = [...round];
const worker = async () => {
while (!cancelled && queue.length) {
const item = queue.shift();
if (item) await analyzeOne(item);
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
}
runningRef.current = false;
if (!cancelled) {
api.getState().setAiStatus({ running: false });
// Faces and/or objects detected this pass → (re)build People & Pets + object albums.
if (provider.detectFaces || provider.detectObjects) {
api.getState().rebuildPeople();
api.getState().syncObjectAlbums();
}
}
};
const timer = setTimeout(() => void run(), START_DELAY_MS);
return () => {
cancelled = true;
runningRef.current = false;
clearTimeout(timer);
};
}, [ready, mediaCount, provider, api]);
return null;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
// Required so the model can read pixels from a cross-origin image.
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useRef } from 'react';
import { useIsMobile } from '../hooks/useMediaQuery';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { Sidebar } from './Sidebar';
import { SelectionBar } from './SelectionBar';
import { TopToolbar } from './TopToolbar';
import { ViewRouter } from './ViewRouter';
export function AppShell() {
const api = useGalleryStoreApi();
const sidebarOpen = useGallery((s) => s.sidebarOpen);
const isMobile = useIsMobile();
useKeyboardShortcuts();
// Collapse the sidebar by default on phones; expand on larger screens.
const lastMobile = useRef<boolean | null>(null);
useEffect(() => {
if (lastMobile.current === isMobile) return;
lastMobile.current = isMobile;
api.getState().setSidebar(!isMobile);
}, [isMobile, api]);
return (
<>
<Sidebar />
<div
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
.filter(Boolean)
.join(' ')}
onClick={() => api.getState().setSidebar(false)}
/>
<div className="apg-main">
<TopToolbar />
<div className="apg-viewport">
<ViewRouter />
<SelectionBar />
</div>
</div>
</>
);
}
@@ -0,0 +1,381 @@
'use client';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../icons';
import { createMediaItem } from '../lib/media';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { Annotation, MediaItem } from '../types';
import { Annotations, type AnnotationTool } from './editor/Annotations';
type Mode = 'photo' | 'video';
type Facing = 'user' | 'environment';
const ANN_TOOLS: Array<{ tool: AnnotationTool; label: string; icon: 'check' | 'aspect' | 'chevron-right' | 'crop' | 'tag' | 'wand' }> = [
{ tool: 'select', label: 'Off', icon: 'check' },
{ tool: 'rect', label: 'Box', icon: 'aspect' },
{ tool: 'arrow', label: 'Arrow', icon: 'chevron-right' },
{ tool: 'double-arrow', label: 'Measure', icon: 'crop' },
{ tool: 'text', label: 'Text', icon: 'tag' },
{ tool: 'freehand', label: 'Draw', icon: 'wand' },
];
const ANN_COLORS = ['#ff3b30', '#ffd60a', '#34c759', '#0a84ff', '#ffffff'];
export function Camera() {
const api = useGalleryStoreApi();
const open = useGallery((s) => s.cameraOpen);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const recordStartRef = useRef<number>(0);
const locationRef = useRef<MediaItem['location']>(undefined);
const deviceRef = useRef<{ label?: string; width?: number; height?: number }>({});
const [mode, setMode] = useState<Mode>('photo');
const [facing, setFacing] = useState<Facing>('environment');
const [grid, setGrid] = useState(false);
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState('#ff3b30');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [recording, setRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const [review, setReview] = useState<{ url: string; blob: Blob; kind: 'image' | 'video'; w: number; h: number } | null>(null);
// Start / restart the camera stream when open or facing changes.
useEffect(() => {
if (!open) return;
let cancelled = false;
setError(null);
(async () => {
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Camera API not available in this browser.');
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: true,
}).catch(async () =>
navigator.mediaDevices.getUserMedia({ video: { facingMode: facing } }),
);
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const track = stream.getVideoTracks()[0];
const settings = track?.getSettings?.() ?? {};
deviceRef.current = { label: track?.label || 'Web Camera', width: settings.width, height: settings.height };
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play().catch(() => undefined);
}
} catch (e) {
setError(
e instanceof Error && e.name === 'NotAllowedError'
? 'Camera permission denied. Allow camera access and try again.'
: 'Could not start the camera.',
);
}
})();
return () => {
cancelled = true;
stopStream();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, facing]);
// Prefetch location when the camera opens so a fix is ready by capture time.
useEffect(() => {
if (!open) return;
let cancelled = false;
void getCurrentLocation().then((loc) => {
if (!cancelled && loc) locationRef.current = loc;
});
return () => {
cancelled = true;
};
}, [open]);
// Reset transient state when closing.
useEffect(() => {
if (!open) {
setReview((r) => {
if (r) URL.revokeObjectURL(r.url);
return null;
});
setAnnotations([]);
setRecording(false);
}
}, [open]);
if (!open) return null;
const stopStream = () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
const close = () => {
if (review) URL.revokeObjectURL(review.url);
stopStream();
api.getState().closeCamera();
};
const capturePhoto = () => {
const video = videoRef.current;
if (!video || !video.videoWidth) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Mirror the front camera to match the on-screen preview.
if (facing === 'user') {
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(video, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return;
setReview({ url: URL.createObjectURL(blob), blob, kind: 'image', w: canvas.width, h: canvas.height });
},
'image/jpeg',
0.92,
);
};
const toggleRecord = () => {
if (recording) {
recorderRef.current?.stop();
return;
}
const stream = streamRef.current;
if (!stream) return;
chunksRef.current = [];
const types = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
const mimeType = types.find((t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t));
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'video/webm' });
const v = videoRef.current;
setReview({
url: URL.createObjectURL(blob),
blob,
kind: 'video',
w: v?.videoWidth ?? 1280,
h: v?.videoHeight ?? 720,
});
setRecording(false);
};
recorder.start();
recorderRef.current = recorder;
recordStartRef.current = Date.now();
setRecording(true);
};
const usePhoto = async () => {
if (!review) return;
const now = Date.now();
const id = nanoid(10);
const location = locationRef.current ?? (await getCurrentLocation());
const device = deviceRef.current;
const exif: Record<string, string | number> = {
Make: 'Web Camera',
Model: device.label || 'Web Camera',
};
if (device.width && device.height) exif.Resolution = `${device.width}×${device.height}`;
const duration =
review.kind === 'video'
? Math.max(1, Math.round((Date.now() - recordStartRef.current) / 1000))
: undefined;
// Prefer the backend (Supabase Storage) for a durable URL; otherwise fall back
// to a data URL (durable across reloads for BOTH photos and video). Only if that
// fails do we keep the in-session object URL as a last resort.
const uploaded = await api.getState().uploadBlob(id, review.blob);
const src = uploaded ?? (await blobToDataUrl(review.blob).catch(() => review.url));
const item: MediaItem = createMediaItem({
id,
src,
name: `${review.kind === 'video' ? 'VID' : 'IMG'}_${formatStamp(now)}.${review.kind === 'video' ? 'webm' : 'jpg'}`,
kind: review.kind,
mime: review.kind === 'video' ? (review.blob.type || 'video/webm') : 'image/jpeg',
bytes: review.blob.size,
width: review.w,
height: review.h,
takenAt: now,
source: 'camera',
duration,
location,
exif,
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
});
api.getState().addMedia([item]);
setReview(null);
setAnnotations([]);
close();
api.getState().setView('library');
// Auto object-detection (AIAnalyzer) will tag the new capture when an AI provider is configured.
};
const retake = () => {
if (review) URL.revokeObjectURL(review.url);
setReview(null);
setAnnotations([]);
};
return (
<div className="apg-camera" role="dialog" aria-modal="true" aria-label="Camera">
<div className="apg-camera__bar">
<button type="button" className="apg-iconbtn" aria-label="Close camera" onClick={close}>
<Icon name="close" />
</button>
<div className="apg-segmented" role="tablist" style={{ margin: '0 auto' }}>
{(['photo', 'video'] as Mode[]).map((m) => (
<button
key={m}
type="button"
className={['apg-segmented__item', mode === m ? 'apg-segmented__item--active' : ''].join(' ')}
onClick={() => !recording && setMode(m)}
>
{m === 'photo' ? 'Photo' : 'Video'}
</button>
))}
</div>
<button type="button" className={['apg-iconbtn', grid ? 'apg-iconbtn--on' : ''].join(' ')} aria-label="Grid" onClick={() => setGrid((g) => !g)}>
<Icon name="aspect" />
</button>
<button type="button" className="apg-iconbtn" aria-label="Switch camera" onClick={() => setFacing((f) => (f === 'user' ? 'environment' : 'user'))}>
<Icon name="rotate" />
</button>
</div>
<div className="apg-camera__stage">
{error ? (
<div className="apg-camera__error">
<Icon name="camera" size={42} />
<p>{error}</p>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => setFacing((f) => f)}>
Retry
</button>
</div>
) : review ? (
<div className="apg-camera__preview">
{review.kind === 'image' ? (
<div style={{ position: 'relative', display: 'inline-flex', maxWidth: '100%', maxHeight: '78vh' }}>
<img src={review.url} alt="Captured" />
<Annotations
annotations={annotations}
editable
tool={annTool}
color={annColor}
onChange={setAnnotations}
/>
</div>
) : (
<video src={review.url} controls autoPlay loop />
)}
</div>
) : (
<div className="apg-camera__videowrap" style={{ transform: facing === 'user' ? 'scaleX(-1)' : undefined }}>
<video ref={videoRef} playsInline muted autoPlay />
{grid ? <div className="apg-camera__grid" /> : null}
</div>
)}
</div>
{review?.kind === 'image' ? (
<div className="apg-camera__markup">
{ANN_TOOLS.map((t) => (
<button
key={t.tool}
type="button"
className={['apg-camera__tool', annTool === t.tool ? 'apg-camera__tool--active' : ''].join(' ')}
onClick={() => setAnnTool(t.tool)}
>
<Icon name={t.icon} size={15} /> {t.label}
</button>
))}
<span style={{ width: 1, background: 'rgba(255,255,255,0.2)', margin: '0 4px' }} />
{ANN_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setAnnColor(c)}
className="apg-camera__swatch"
style={{ background: c, outline: annColor === c ? '2px solid #fff' : 'none' }}
/>
))}
<span style={{ width: 1, background: 'rgba(255,255,255,0.2)', margin: '0 4px' }} />
<button
type="button"
className="apg-camera__tool"
disabled={annotations.length === 0}
onClick={() => setAnnotations((a) => a.slice(0, -1))}
>
<Icon name="rotate" size={15} /> Undo
</button>
<button
type="button"
className="apg-camera__tool"
disabled={annotations.length === 0}
onClick={() => setAnnotations([])}
>
<Icon name="trash" size={15} /> Clear
</button>
</div>
) : null}
<div className="apg-camera__controls">
{review ? (
<>
<button type="button" className="apg-btn" onClick={retake}>
Retake
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void usePhoto()}>
Use {review.kind === 'video' ? 'Video' : 'Photo'}
</button>
</>
) : (
<button
type="button"
className={['apg-camera__shutter', mode === 'video' ? 'apg-camera__shutter--video' : '', recording ? 'apg-camera__shutter--recording' : ''].join(' ')}
aria-label={mode === 'video' ? (recording ? 'Stop recording' : 'Record') : 'Capture photo'}
disabled={Boolean(error)}
onClick={mode === 'photo' ? capturePhoto : toggleRecord}
/>
)}
</div>
</div>
);
}
function getCurrentLocation(): Promise<MediaItem['location'] | undefined> {
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) return resolve(undefined);
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => resolve(undefined),
{ enableHighAccuracy: true, timeout: 10_000, maximumAge: 300_000 },
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('read failed'));
reader.readAsDataURL(blob);
});
}
function formatStamp(ms: number): string {
const d = new Date(ms);
const p = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
@@ -0,0 +1,166 @@
'use client';
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Icon, type IconName } from '../icons';
export interface MenuItem {
type?: 'item';
label: string;
icon?: IconName;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
checked?: boolean;
}
export interface MenuSeparator {
type: 'separator';
}
export interface MenuLabel {
type: 'label';
label: string;
}
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel;
interface MenuState {
x: number;
y: number;
entries: MenuEntry[];
}
// Module-level emitter — typically one gallery is interactive at a time.
let current: MenuState | null = null;
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
export function openContextMenu(x: number, y: number, entries: MenuEntry[]) {
current = { x, y, entries };
emit();
}
export function closeContextMenu() {
if (current) {
current = null;
emit();
}
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
function getSnapshot() {
return current;
}
/** Renders the active context menu (mounted once near the gallery root). */
export function ContextMenuHost() {
const state = useSyncExternalStore(subscribe, getSnapshot, () => null);
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
useLayoutEffect(() => {
if (!state || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
const pad = 8;
const left = Math.min(state.x, window.innerWidth - rect.width - pad);
const top = Math.min(state.y, window.innerHeight - rect.height - pad);
setPos({ left: Math.max(pad, left), top: Math.max(pad, top) });
// Move keyboard focus to the first actionable item when the menu opens.
ref.current.querySelector<HTMLButtonElement>('button.apg-menu__item:not([disabled])')?.focus();
}, [state]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const items = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>('button.apg-menu__item:not([disabled])') ?? [],
);
if (items.length === 0) return;
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
if (e.key === 'ArrowDown') {
e.preventDefault();
items[(idx + 1) % items.length]!.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items[(idx - 1 + items.length) % items.length]!.focus();
} else if (e.key === 'Home') {
e.preventDefault();
items[0]!.focus();
} else if (e.key === 'End') {
e.preventDefault();
items[items.length - 1]!.focus();
}
};
useEffect(() => {
if (!state) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) closeContextMenu();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
const onScroll = () => closeContextMenu();
window.addEventListener('mousedown', onDown, true);
window.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('mousedown', onDown, true);
window.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
};
}, [state]);
if (!state) return null;
return (
<div
ref={ref}
className="apg-menu"
role="menu"
style={{ left: pos.left, top: pos.top }}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={onMenuKeyDown}
>
{state.entries.map((entry, i) => {
if (entry.type === 'separator') return <div key={i} className="apg-menu__sep" />;
if (entry.type === 'label')
return (
<div key={i} className="apg-menu__label">
{entry.label}
</div>
);
return (
<button
key={i}
type="button"
role="menuitem"
className={['apg-menu__item', entry.danger ? 'apg-menu__item--danger' : '']
.filter(Boolean)
.join(' ')}
disabled={entry.disabled}
onClick={() => {
closeContextMenu();
entry.onClick();
}}
>
{entry.icon ? (
<span className="apg-menu__icon">
<Icon name={entry.icon} size={16} />
</span>
) : (
<span className="apg-menu__icon" />
)}
<span>{entry.label}</span>
{entry.checked ? (
<span className="apg-menu__check">
<Icon name="check" size={15} />
</span>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,407 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { sourceLabel } from '../lib/classify';
import { editFilterCss, editTransformCss } from '../lib/edits';
import { formatBytes, formatDate, formatDuration, formatTime } from '../lib/format';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaItem } from '../types';
export function InfoPanel() {
const api = useGalleryStoreApi();
const open = useGallery((s) => s.infoOpen);
const media = useGallery((s) => s.media);
const lightboxId = useGallery((s) => s.lightboxId);
const selection = useGallery((s) => s.selection);
if (!open) return null;
// Target: the lightbox item, else the single selected item.
const targetId = lightboxId ?? (selection.size === 1 ? [...selection][0] : undefined);
const item = targetId ? (media.find((m) => m.id === targetId) ?? null) : null;
const ext = item?.name.includes('.') ? item.name.split('.').pop()!.toUpperCase() : '—';
const mp = item ? ((item.width * item.height) / 1_000_000).toFixed(1) : '0';
return (
<aside className="apg-info" aria-label="Info">
<div className="apg-info__head">
<span style={{ fontWeight: 700, fontSize: 15 }}>Info</span>
<button
type="button"
className="apg-iconbtn"
aria-label="Close info"
onClick={() => api.getState().setInfoOpen(false)}
>
<Icon name="close" size={18} />
</button>
</div>
{!item ? (
<div className="apg-info__empty">Select a single photo to see its details.</div>
) : (
<div className="apg-info__body">
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
<div className="apg-info__name">{item.name}</div>
<div className="apg-info__sub">
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
</div>
<Row label="Kind" value={item.kind === 'video' ? 'Video' : 'Photo'} />
<Row label="Source" value={sourceLabel(item.source)} />
<Row label="Format" value={`${item.mime} (${ext})`} />
<Row label="Dimensions" value={`${item.width} × ${item.height} · ${mp} MP`} />
<Row label="Size" value={formatBytes(item.bytes)} />
{item.duration ? <Row label="Duration" value={formatDuration(item.duration)} /> : null}
{item.exif?.Make || item.exif?.Model ? (
<Row label="Camera" value={`${item.exif.Make ?? ''} ${item.exif.Model ?? ''}`.trim()} />
) : null}
{item.exif?.FNumber ? (
<Row
label="Exposure"
value={[
item.exif.FNumber ? `ƒ${item.exif.FNumber}` : '',
item.exif.ISO ? `ISO ${item.exif.ISO}` : '',
item.exif.FocalLength ? `${item.exif.FocalLength}mm` : '',
]
.filter(Boolean)
.join(' · ')}
/>
) : null}
{item.objectLabels.length ? (
<Chips
label="Objects"
items={item.objectLabels}
onClick={(o) => {
const s = api.getState();
s.setObjectFocus(o);
s.closeLightbox();
s.setInfoOpen(false);
}}
/>
) : null}
{item.tags.length ? (
<Chips
label="Tags"
items={item.tags}
onClick={(t) => {
const s = api.getState();
s.setTagFocus(t);
s.closeLightbox();
s.setInfoOpen(false);
}}
/>
) : null}
{item.location ? (
<>
<Row
label="Location"
value={
item.location.place ??
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
}
/>
<div className="apg-info__coords">
{item.location.lat.toFixed(5)}, {item.location.lng.toFixed(5)}
</div>
<MiniMap
lat={item.location.lat}
lng={item.location.lng}
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
/>
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
Click the map to open it in full.
</div>
</>
) : null}
<Versions item={item} />
<Comments item={item} />
</div>
)}
</aside>
);
}
/** Version history + audit log for a photo/video (v1 = original). */
function Versions({ item }: { item: MediaItem }) {
const api = useGalleryStoreApi();
const [openId, setOpenId] = useState<string | null>(null);
const versions = item.versions ?? [];
// Newest first; the last entry is the current one.
const ordered = [...versions].reverse();
const currentId = versions.length ? versions[versions.length - 1]!.id : null;
return (
<div className="apg-info__section">
<div className="apg-info__section-head">
<Icon name="clock" size={15} />
<span>Version history</span>
<span className="apg-info__count">{versions.length || 1}</span>
</div>
{versions.length === 0 ? (
<div className="apg-info__hint">
Only the original exists. Edits create new versions the original is never overwritten.
</div>
) : (
<ol className="apg-versions">
{ordered.map((v) => {
const isOpen = openId === v.id;
const isCurrent = v.id === currentId;
const isOriginal = v.version === 1;
return (
<li key={v.id} className="apg-version">
<button
type="button"
className={`apg-version__row${isCurrent ? ' is-current' : ''}`}
onClick={() => setOpenId(isOpen ? null : v.id)}
aria-expanded={isOpen}
>
<span className="apg-version__thumb-wrap">
<img
className="apg-version__thumb"
src={v.thumbnail ?? v.src ?? item.thumbnail ?? item.src}
alt=""
style={{
filter: editFilterCss(v.edits) || undefined,
transform: editTransformCss(v.edits) || undefined,
}}
/>
</span>
<span className="apg-version__meta">
<span className="apg-version__title">
{isOriginal ? 'Original' : `Version ${v.version}`}
{isCurrent ? <span className="apg-version__badge">Current</span> : null}
</span>
<span className="apg-version__time">
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
</span>
</span>
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
</button>
{isOpen ? (
<div className="apg-version__detail">
<div className="apg-version__changes-label">What changed</div>
<ul className="apg-version__changes">
{v.changes.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
{!isCurrent ? (
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() => api.getState().restoreVersion(item.id, v.id)}
>
Restore this version
</button>
) : null}
</div>
) : null}
</li>
);
})}
</ol>
)}
</div>
);
}
/** Threaded comments on a photo/video. */
function Comments({ item }: { item: MediaItem }) {
const api = useGalleryStoreApi();
const comments = item.comments ?? [];
const [text, setText] = useState('');
const [author, setAuthor] = useState(() => {
if (typeof window === 'undefined') return 'You';
return window.localStorage.getItem('apg:comment-author') || 'You';
});
const post = () => {
const t = text.trim();
if (!t) return;
const name = author.trim() || 'You';
try {
window.localStorage.setItem('apg:comment-author', name);
} catch {
/* ignore */
}
api.getState().addComment(item.id, t, name);
setText('');
};
return (
<div className="apg-info__section">
<div className="apg-info__section-head">
<Icon name="chat" size={15} />
<span>Comments</span>
<span className="apg-info__count">{comments.length}</span>
</div>
{comments.length === 0 ? (
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
) : (
<ul className="apg-comments">
{comments.map((c) => (
<li key={c.id} className="apg-comment">
<div className="apg-comment__avatar" aria-hidden>
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
</div>
<div className="apg-comment__body">
<div className="apg-comment__meta">
<span className="apg-comment__author">{c.author ?? 'You'}</span>
<span className="apg-comment__time">
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
</span>
</div>
<div className="apg-comment__text">{c.text}</div>
</div>
<button
type="button"
className="apg-comment__delete"
aria-label="Delete comment"
onClick={() => api.getState().deleteComment(item.id, c.id)}
>
<Icon name="close" size={13} />
</button>
</li>
))}
</ul>
)}
<div className="apg-comment-form">
<input
className="apg-comment-form__author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="Your name"
aria-label="Your name"
maxLength={40}
/>
<textarea
className="apg-comment-form__input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
}}
placeholder="Add a comment…"
aria-label="Add a comment"
rows={2}
maxLength={2000}
/>
<button
type="button"
className="apg-btn apg-btn--small apg-comment-form__post"
onClick={post}
disabled={!text.trim()}
>
Post
</button>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="apg-info__row">
<span className="apg-info__row-label">{label}</span>
<span className="apg-info__row-value">{value}</span>
</div>
);
}
function Chips({
label,
items,
onClick,
}: {
label: string;
items: string[];
onClick?: (item: string) => void;
}) {
return (
<div className="apg-info__chips">
<span className="apg-info__row-label">{label}</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{items.map((t) => (
<button
key={t}
type="button"
className="apg-chip"
onClick={onClick ? () => onClick(t) : undefined}
style={{ cursor: onClick ? 'pointer' : 'default', textTransform: 'capitalize' }}
>
{t}
</button>
))}
</div>
</div>
);
}
/** Small non-interactive Leaflet map for the location preview (click to open full Map). */
function MiniMap({ lat, lng, onOpen }: { lat: number; lng: number; onOpen?: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
useEffect(() => {
let cancelled = false;
void import('leaflet').then((mod) => {
const L = (mod as any).default ?? mod;
if (cancelled || !ref.current || mapRef.current) return;
const map = L.map(ref.current, {
zoomControl: false,
attributionControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
tap: false,
}).setView([lat, lng], 11);
mapRef.current = map;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
L.circleMarker([lat, lng], {
radius: 7,
color: '#fff',
weight: 2,
fillColor: '#0a84ff',
fillOpacity: 1,
}).addTo(map);
});
return () => {
cancelled = true;
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [lat, lng]);
return (
<div style={{ position: 'relative' }}>
<div ref={ref} className="apg-info__map" />
{onOpen ? (
<button
type="button"
aria-label="Open in Map"
onClick={onOpen}
style={{
position: 'absolute',
inset: 0,
background: 'transparent',
border: 'none',
cursor: 'pointer',
zIndex: 500,
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,199 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useRef } from 'react';
import { downloadMedia } from '../lib/download';
import { editFilterCss, editTransformCss } from '../lib/edits';
import { formatDate, formatTime } from '../lib/format';
import { Icon } from '../icons';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { useViewMedia } from '../hooks/useViewMedia';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { Annotations } from './editor/Annotations';
import { openShareModal } from './modals';
import { VideoPlayer } from './VideoPlayer';
export function Lightbox() {
const api = useGalleryStoreApi();
const lightboxId = useGallery((s) => s.lightboxId);
const media = useGallery((s) => s.media);
const features = useGallery((s) => s.config.features);
const ordered = useViewMedia();
const item = media.find((m) => m.id === lightboxId) ?? null;
const index = ordered.findIndex((m) => m.id === lightboxId);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape is handled by the window listener below; trap only manages Tab + focus.
useFocusTrap(dialogRef, Boolean(item));
const go = useCallback(
(dir: -1 | 1) => {
if (index === -1) return;
const next = ordered[index + dir];
if (next) api.getState().openLightbox(next.id);
},
[api, index, ordered],
);
useEffect(() => {
if (!lightboxId) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') api.getState().closeLightbox();
else if (e.key === 'ArrowLeft') go(-1);
else if (e.key === 'ArrowRight') go(1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [lightboxId, api, go]);
return (
<AnimatePresence>
{item ? (
<motion.div
ref={dialogRef}
className="apg-lightbox"
role="dialog"
aria-modal="true"
aria-label={`Photo: ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<div className="apg-lightbox__bar">
<button
type="button"
className="apg-iconbtn"
aria-label="Close"
onClick={() => api.getState().closeLightbox()}
>
<Icon name="chevron-left" />
</button>
<div>
<div className="apg-lightbox__title">{item.name}</div>
<div className="apg-lightbox__sub">
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
{item.location?.place ? ` · ${item.location.place}` : ''}
</div>
</div>
<div style={{ flex: 1 }} />
<button
type="button"
className="apg-iconbtn"
aria-label="Info"
onClick={() => api.getState().toggleInfo()}
>
<Icon name="info" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label={item.favorite ? 'Unfavourite' : 'Favourite'}
aria-pressed={item.favorite}
style={item.favorite ? { color: '#ff3b30' } : undefined}
onClick={() => api.getState().toggleFavorite([item.id])}
>
<Icon name={item.favorite ? 'heart-fill' : 'heart'} />
</button>
{features.editor ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Edit"
onClick={() => api.getState().openEditor(item.id)}
>
<Icon name="adjust" />
</button>
) : null}
{features.sharing ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Share"
onClick={() => openShareModal([item.id])}
>
<Icon name="share" />
</button>
) : null}
{features.export ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Download"
onClick={() => downloadMedia(item)}
>
<Icon name="download" />
</button>
) : null}
<button
type="button"
className="apg-iconbtn"
aria-label="Delete"
onClick={() => {
api.getState().trash([item.id]);
api.getState().closeLightbox();
}}
>
<Icon name="trash" />
</button>
</div>
<div className="apg-lightbox__stage">
{index > 0 ? (
<button
type="button"
className="apg-lightbox__nav apg-lightbox__nav--prev"
aria-label="Previous"
onClick={() => go(-1)}
>
<Icon name="chevron-left" />
</button>
) : null}
<motion.div
key={item.id}
initial={{ opacity: 0.4, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.16 }}
style={{ maxWidth: '100%', maxHeight: '100%', display: 'flex' }}
>
{item.kind === 'video' ? (
<VideoPlayer src={item.src} poster={item.poster} filter={editFilterCss(item.edits)} />
) : (
<div style={{ position: 'relative', display: 'inline-block', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={item.src}
alt={item.name}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
filter: editFilterCss(item.edits),
transform: editTransformCss(item.edits),
}}
/>
{item.edits?.annotations?.length ? (
<Annotations annotations={item.edits.annotations} editable={false} />
) : null}
</div>
)}
</motion.div>
{index < ordered.length - 1 && index !== -1 ? (
<button
type="button"
className="apg-lightbox__nav apg-lightbox__nav--next"
aria-label="Next"
onClick={() => go(1)}
>
<Icon name="chevron-right" />
</button>
) : null}
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -0,0 +1,45 @@
'use client';
import { useMemo } from 'react';
import { GRID_ZOOM_STEPS } from '../constants';
import { useGallery } from '../store/context';
import type { MediaItem } from '../types';
import { PhotoTile } from './PhotoTile';
/** Minimum tile width (px) for each zoom step — drives responsive auto-fill. */
const TILE_MIN_BY_ZOOM = [104, 124, 150, 188, 232, 300, 420];
export interface MediaGridProps {
items: MediaItem[];
/** Optional sticky section title rendered above the grid. */
title?: string;
}
/**
* Adaptive, responsive photo grid. Column count follows the zoom level via CSS
* `auto-fill`, so it reflows naturally from ultra-wide screens down to a phone.
* Images are lazy-decoded for performance with large libraries.
*/
export function MediaGrid({ items, title }: MediaGridProps) {
const zoomIndex = useGallery((s) => s.zoomIndex);
const orderedIds = useMemo(() => items.map((i) => i.id), [items]);
const tileMin = TILE_MIN_BY_ZOOM[Math.min(zoomIndex, TILE_MIN_BY_ZOOM.length - 1)] ?? 188;
return (
<div>
{title ? <div className="apg-grid__section-title">{title}</div> : null}
<div
className="apg-grid"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileMin}px, 1fr))` }}
>
{items.map((item) => (
<PhotoTile key={item.id} item={item} orderedIds={orderedIds} />
))}
</div>
</div>
);
}
export { GRID_ZOOM_STEPS };
@@ -0,0 +1,56 @@
'use client';
import { type ReactNode, useEffect, useRef, useSyncExternalStore } from 'react';
import { useFocusTrap } from '../hooks/useFocusTrap';
// Module-level modal emitter (mirrors the context-menu host pattern).
let current: ReactNode | null = null;
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
export function openModal(node: ReactNode) {
current = node;
emit();
}
export function closeModal() {
current = null;
emit();
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function ModalHost() {
const node = useSyncExternalStore(
subscribe,
() => current,
() => null,
);
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, Boolean(node), closeModal);
useEffect(() => {
if (!node) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [node]);
if (!node) return null;
return (
<div
ref={ref}
className="apg-modal__backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
{node}
</div>
);
}
@@ -0,0 +1,244 @@
'use client';
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react';
import { createLocalStorageAdapter } from '../adapters/localStorage';
import type { StorageAdapter } from '../adapters/types';
import type { AIProvider } from '../ai/types';
import { normalizeMediaItem, type MediaInput } from '../lib/media';
import { GalleryStoreContext, useGallery, useGalleryStoreApi } from '../store/context';
import {
createGalleryStore,
DEFAULT_FEATURES,
type GalleryConfig,
type GalleryFeatures,
type GalleryStore,
type ThemeTokens,
} from '../store/store';
import type { Album, MediaItem, ThemePreference } from '../types';
import { AIAnalyzer } from './AIAnalyzer';
import { SemanticSearch } from './SemanticSearch';
import { AIProviderContext } from './aiContext';
import { AppShell } from './AppShell';
import { Camera } from './Camera';
import { ContextMenuHost } from './ContextMenu';
import { PhotoEditor } from './editor/PhotoEditor';
import { VideoEditor } from './editor/VideoEditor';
import { InfoPanel } from './InfoPanel';
import { Lightbox } from './Lightbox';
import { ModalHost } from './Modal';
export interface PhotoGalleryProps {
/** Initial media. Accepts full MediaItems or loose `{ src, name?, ... }` inputs. */
photos?: Array<MediaItem | MediaInput>;
/** Initial user albums. */
albums?: Album[];
/** Storage backend. Defaults to a zero-config localStorage adapter. */
adapter?: StorageAdapter;
/** AI provider for object/face/caption/search. Pass `false` to disable. */
ai?: AIProvider | boolean;
theme?: ThemePreference;
accentColor?: string;
/** Base corner radius in px (default 10). Drives all rounded UI. */
borderRadius?: number;
/** Per-theme color / gradient / radius overrides (mapped to CSS variables). */
themeTokens?: ThemeTokens;
features?: Partial<GalleryFeatures>;
/** Render the macOS-style traffic-light title bar. */
showWindowChrome?: boolean;
title?: string;
className?: string;
style?: CSSProperties;
onReady?: () => void;
}
export function PhotoGallery(props: PhotoGalleryProps) {
const {
photos,
albums,
adapter,
ai,
theme = 'system',
accentColor,
borderRadius,
themeTokens,
features,
showWindowChrome = false,
title = 'Photos',
className,
style,
onReady,
} = props;
// Resolve adapter / AI once.
const adapterRef = useRef<StorageAdapter | null>(null);
if (!adapterRef.current) {
adapterRef.current = adapter ?? createLocalStorageAdapter();
}
const aiProvider: AIProvider | null = useMemo(
() => (ai && typeof ai === 'object' ? ai : null),
[ai],
);
const config: GalleryConfig = useMemo(
() => ({
features: { ...DEFAULT_FEATURES, ...features },
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
borderRadius: borderRadius ?? 10,
showWindowChrome,
title,
themeTokens,
}),
[features, accentColor, borderRadius, themeTokens, showWindowChrome, title],
);
// Create the store exactly once for this gallery instance.
const [store] = useState<GalleryStore>(() =>
createGalleryStore({
config,
// Normalize + sanitize every input (loose or full MediaItem) through one path.
initialMedia: (photos ?? [])
.map((p) => normalizeMediaItem(p))
.filter((m): m is MediaItem => m !== null),
initialAlbums: albums ?? [],
}),
);
// Apply the initial theme preference into the store.
useEffect(() => {
store.getState().setTheme(theme);
}, [store, theme]);
return (
<GalleryStoreContext.Provider value={store}>
<GalleryRoot
adapter={adapterRef.current}
ai={aiProvider}
className={className}
style={style}
onReady={onReady}
/>
</GalleryStoreContext.Provider>
);
}
interface GalleryRootProps {
adapter: StorageAdapter;
ai: AIProvider | null;
className?: string;
style?: CSSProperties;
onReady?: () => void;
}
function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProps) {
const api = useGalleryStoreApi();
const ready = useGallery((s) => s.ready);
const theme = useGallery((s) => s.theme);
const resolvedTheme = useGallery((s) => s.resolvedTheme);
const accent = useGallery((s) => s.config.accentColor);
const radius = useGallery((s) => s.config.borderRadius);
const tokens = useGallery((s) => s.config.themeTokens);
const showChrome = useGallery((s) => s.config.showWindowChrome);
const aiEnabled = useGallery((s) => s.config.features.ai);
const cameraEnabled = useGallery((s) => s.config.features.camera);
// Initialize (load persisted state) EXACTLY once — the ref guard prevents a
// double init() (React Strict Mode / remounts) from re-seeding an empty backend.
const initedRef = useRef(false);
useEffect(() => {
if (initedRef.current) return;
initedRef.current = true;
void api.getState().init(adapter, ai);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (ready) onReady?.();
}, [ready, onReady]);
// Resolve "system" theme and react to OS changes.
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) {
api.getState().setResolvedTheme(theme === 'system' ? 'light' : theme);
return;
}
if (theme !== 'system') {
api.getState().setResolvedTheme(theme);
return;
}
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => api.getState().setResolvedTheme(mq.matches ? 'dark' : 'light');
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, [api, theme]);
// Map optional theme tokens → CSS variables for the active theme. Dark values
// apply in dark mode and to the semi-dark sidebar; light values elsewhere.
const dark = resolvedTheme === 'dark';
const tokenVars: Record<string, string> = {};
if (tokens) {
// Colors/gradients only — reject url()/expression()/JS or CSS breakout chars
// (defense-in-depth; tokens normally come from build-time env, not user input).
const CSS_UNSAFE = /(url\(|expression\(|javascript:|[<>{}])/i;
const set = (v: string | undefined, name: string) => {
if (v && !CSS_UNSAFE.test(v)) tokenVars[name] = v;
};
const bg = dark ? tokens.bgDark : tokens.bgLight;
set(bg, '--apg-bg');
set(bg, '--apg-bg-content');
set(dark ? tokens.elevatedDark : tokens.elevatedLight, '--apg-bg-elevated');
// In semi-dark the sidebar is always the dark glass value.
set(resolvedTheme === 'semi-dark' ? tokens.sidebarBgDark : dark ? tokens.sidebarBgDark : tokens.sidebarBgLight, '--apg-sidebar-bg');
set(dark ? tokens.textDark : tokens.textLight, '--apg-text');
if (typeof tokens.sidebarRadius === 'number') tokenVars['--apg-sidebar-radius'] = `${tokens.sidebarRadius}px`;
}
const rootStyle: CSSProperties = {
['--apg-accent' as string]: accent,
['--apg-radius' as string]: `${radius}px`,
['--apg-radius-sm' as string]: `${Math.max(2, Math.round(radius * 0.6))}px`,
['--apg-radius-lg' as string]: `${Math.round(radius * 1.4)}px`,
['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`,
...tokenVars,
...style,
};
return (
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
<div
className={['apg', className].filter(Boolean).join(' ')}
data-theme={resolvedTheme}
style={rootStyle}
>
{showChrome ? <WindowChrome /> : null}
<div className="apg__body">
<AppShell />
</div>
<Lightbox />
<PhotoEditor />
<VideoEditor />
<InfoPanel />
{cameraEnabled ? <Camera /> : null}
<ModalHost />
<ContextMenuHost />
{ai && aiEnabled ? <AIAnalyzer provider={ai} /> : null}
{ai && aiEnabled ? <SemanticSearch provider={ai} /> : null}
</div>
</AIProviderContext.Provider>
);
}
function WindowChrome() {
const title = useGallery((s) => s.config.title);
return (
<div className="apg-titlebar">
<div className="apg-traffic">
<span className="apg-traffic__dot apg-traffic__dot--red" />
<span className="apg-traffic__dot apg-traffic__dot--yellow" />
<span className="apg-traffic__dot apg-traffic__dot--green" />
</div>
<div className="apg-titlebar__title">{title}</div>
</div>
);
}
@@ -0,0 +1,194 @@
'use client';
import { memo } from 'react';
import { downloadMedia } from '../lib/download';
import { editFilterCss, editTransformCss } from '../lib/edits';
import { formatDuration } from '../lib/format';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaId, MediaItem } from '../types';
import { openContextMenu } from './ContextMenu';
import { addToAlbumPicker, confirmAction, moveToAlbumPicker, openShareModal } from './modals';
interface PhotoTileProps {
item: MediaItem;
orderedIds: MediaId[];
}
function PhotoTileImpl({ item, orderedIds }: PhotoTileProps) {
const api = useGalleryStoreApi();
const selected = useGallery((s) => s.selection.has(item.id));
const view = useGallery((s) => s.view);
const features = useGallery((s) => s.config.features);
const inTrash = view === 'recently-deleted';
const targetIds = () => {
const sel = api.getState().selection;
return sel.has(item.id) && sel.size > 1 ? [...sel] : [item.id];
};
const onClick = (e: React.MouseEvent) => {
if (e.shiftKey) {
api.getState().select(item.id, { range: true, orderedIds });
} else if (e.metaKey || e.ctrlKey) {
api.getState().select(item.id, { additive: true });
} else if (api.getState().selection.size > 0) {
// In selection mode a plain click extends/toggles the selection.
api.getState().select(item.id, { additive: true });
} else {
// Otherwise a plain click opens the photo (macOS-style); use the checkmark
// or right-click → Select to enter selection mode.
api.getState().openLightbox(item.id);
}
};
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
// Right-click shows the menu WITHOUT changing the selection; actions target
// the current selection if this item is part of it, else just this item.
const sel = api.getState().selection;
const selected = sel.has(item.id);
const ids = targetIds();
if (inTrash) {
openContextMenu(e.clientX, e.clientY, [
{ label: 'Restore', icon: 'rotate', onClick: () => api.getState().restore(ids) },
{
label: 'Delete Permanently',
icon: 'trash',
danger: true,
onClick: () =>
confirmAction({
title: `Delete ${ids.length} item${ids.length === 1 ? '' : 's'}?`,
message: 'This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
onConfirm: () => api.getState().deletePermanently(ids),
}),
},
]);
return;
}
openContextMenu(e.clientX, e.clientY, [
{ label: 'Open', icon: 'image', onClick: () => api.getState().openLightbox(item.id) },
{
label: selected ? 'Deselect' : 'Select',
icon: selected ? 'close' : 'check',
onClick: () => api.getState().select(item.id, { additive: true }),
},
...(features.editor
? [{ label: 'Edit', icon: 'adjust' as const, onClick: () => api.getState().openEditor(item.id) }]
: []),
{
label: item.favorite ? 'Unfavourite' : 'Favourite',
icon: 'heart',
onClick: () => api.getState().toggleFavorite(ids),
},
...(features.sharing
? [{ label: 'Share…', icon: 'share' as const, onClick: () => openShareModal(ids) }]
: []),
{ label: 'Copy to Album…', icon: 'collections', onClick: () => addToAlbumPicker(ids) },
...(view.startsWith('album:')
? [
{
label: 'Move to Album…',
icon: 'collections' as const,
onClick: () => moveToAlbumPicker(view, ids),
},
{
label: 'Remove from Album',
icon: 'close' as const,
onClick: () => api.getState().removeFromAlbum(view, ids),
},
]
: []),
{ type: 'separator' },
...(item.objectLabels.length
? [
{
label: `Find similar: ${item.objectLabels[0]}`,
icon: 'search' as const,
onClick: () => api.getState().setObjectFocus(item.objectLabels[0]!),
},
]
: []),
...(features.export
? [{ label: 'Download', icon: 'download' as const, onClick: () => downloadMedia(item) }]
: []),
{ type: 'separator' },
{
label: ids.length > 1 ? `Delete ${ids.length} Items` : 'Delete',
icon: 'trash',
danger: true,
onClick: () => api.getState().trash(ids),
},
]);
};
const toggleCheck = (e: React.MouseEvent) => {
e.stopPropagation();
api.getState().select(item.id, { additive: true });
};
return (
<div
className={['apg-tile', selected ? 'apg-tile--selected' : ''].join(' ')}
onClick={onClick}
onDoubleClick={() => api.getState().openLightbox(item.id)}
onContextMenu={onContextMenu}
role="button"
aria-label={item.name}
aria-pressed={selected}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') api.getState().openLightbox(item.id);
}}
>
{item.kind === 'video' ? (
<video
src={item.src}
poster={item.poster}
muted
preload="metadata"
playsInline
style={{ filter: editFilterCss(item.edits), transform: editTransformCss(item.edits) }}
/>
) : (
<img
src={item.thumbnail ?? item.src}
alt={item.name}
loading="lazy"
decoding="async"
draggable={false}
style={{ filter: editFilterCss(item.edits), transform: editTransformCss(item.edits) }}
/>
)}
{item.kind === 'video' ? (
<span className="apg-tile__badge">
<Icon name="play" size={12} />
{formatDuration(item.duration)}
</span>
) : null}
{item.favorite ? (
<span className="apg-tile__fav">
<Icon name="heart-fill" size={14} />
</span>
) : null}
<button
type="button"
className="apg-tile__check"
aria-label={selected ? 'Deselect' : 'Select'}
onClick={toggleCheck}
>
{selected ? <Icon name="check" size={13} /> : null}
</button>
</div>
);
}
export const PhotoTile = memo(PhotoTileImpl);
@@ -0,0 +1,127 @@
'use client';
import { downloadMedia, exportMetadata } from '../lib/download';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { addToAlbumPicker, confirmAction, openShareModal } from './modals';
export function SelectionBar() {
const api = useGalleryStoreApi();
const selection = useGallery((s) => s.selection);
const view = useGallery((s) => s.view);
const media = useGallery((s) => s.media);
const features = useGallery((s) => s.config.features);
const count = selection.size;
if (count === 0) return null;
const ids = [...selection];
const inTrash = view === 'recently-deleted';
const exportSelection = () => {
const items = media.filter((m) => selection.has(m.id));
items.forEach((m) => downloadMedia(m));
exportMetadata(items);
};
return (
<div className="apg-actionbar" role="toolbar" aria-label="Selection actions">
<span className="apg-actionbar__count">
{count} Selected
</span>
{inTrash ? (
<>
<button
type="button"
className="apg-iconbtn"
aria-label="Restore"
title="Restore"
onClick={() => api.getState().restore(ids)}
>
<Icon name="rotate" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label="Delete permanently"
title="Delete Permanently"
onClick={() =>
confirmAction({
title: `Delete ${count} item${count === 1 ? '' : 's'}?`,
message: 'These items will be permanently deleted. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
onConfirm: () => api.getState().deletePermanently(ids),
})
}
>
<Icon name="trash" />
</button>
</>
) : (
<>
<button
type="button"
className="apg-iconbtn"
aria-label="Favourite"
title="Favourite"
onClick={() => api.getState().toggleFavorite(ids)}
>
<Icon name="heart" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label="Add to album"
title="Add to Album"
onClick={() => addToAlbumPicker(ids)}
>
<Icon name="collections" />
</button>
{features.sharing ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Share"
title="Share"
onClick={() => openShareModal(ids)}
>
<Icon name="share" />
</button>
) : null}
{features.export ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Download"
title="Download"
onClick={exportSelection}
>
<Icon name="download" />
</button>
) : null}
<button
type="button"
className="apg-iconbtn"
aria-label="Delete"
title="Delete"
onClick={() => api.getState().trash(ids)}
>
<Icon name="trash" />
</button>
</>
)}
<button
type="button"
className="apg-iconbtn"
aria-label="Clear selection"
title="Clear selection"
onClick={() => api.getState().clearSelection()}
>
<Icon name="close" size={18} />
</button>
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
import { useEffect } from 'react';
import { cosineSimilarity, type AIProvider } from '../ai/types';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { liveMedia } from '../store/selectors';
const DEBOUNCE_MS = 350;
const MIN_QUERY_LEN = 2;
/** Cosine-similarity floor for a photo to count as a semantic match. CLIP ViT-B/16
* scores strong matches ~0.25+, unrelated ~0.15 0.22 keeps it crisp. */
const MATCH_THRESHOLD = 0.22;
const MAX_RESULTS = 60;
/**
* Headless worker: when the user types a query, embeds it with the provider's
* text encoder (CLIP) and ranks every photo that has an image embedding by
* cosine similarity, writing the ordered ids to the store. The selector blends
* these "looks like" matches with the keyword results. Runs only if the provider
* supports embedText; otherwise search stays purely keyword-based.
*/
export function SemanticSearch({ provider }: { provider: AIProvider }) {
const api = useGalleryStoreApi();
const query = useGallery((s) => s.searchQuery);
useEffect(() => {
if (!provider.embedText) return;
const q = query.trim();
if (q.length < MIN_QUERY_LEN) {
api.getState().setSemanticResults(null);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
const embedded = liveMedia(api.getState().media).filter((m) => (m.embedding?.length ?? 0) > 0);
if (embedded.length === 0) {
if (!cancelled) api.getState().setSemanticResults(null);
return;
}
const qvec = await provider.embedText!(q).catch(() => [] as number[]);
if (cancelled || qvec.length === 0) return;
const ranked = embedded
.map((m) => ({ id: m.id, score: cosineSimilarity(qvec, m.embedding!) }))
.filter((r) => r.score >= MATCH_THRESHOLD)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_RESULTS)
.map((r) => r.id);
if (!cancelled) api.getState().setSemanticResults(ranked);
}, DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [query, provider, api]);
return null;
}
@@ -0,0 +1,289 @@
'use client';
import { useState } from 'react';
import { useIsMobile } from '../hooks/useMediaQuery';
import { Icon, type IconName } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { Album, ViewId } from '../types';
import { closeContextMenu, openContextMenu } from './ContextMenu';
import { openSecuritySettings, promptAlbumName } from './modals';
interface RowProps {
icon: IconName;
label: string;
view?: ViewId;
trailing?: IconName;
indent?: boolean;
disclosure?: boolean;
open?: boolean;
/** Suppress active highlight (for duplicate rows that share a view). */
noActive?: boolean;
onToggle?: () => void;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
/** When set, the trailing icon becomes its own clickable control. */
onTrailingClick?: () => void;
trailingLabel?: string;
}
function Row({
icon,
label,
view,
trailing,
indent,
disclosure,
open,
noActive,
onToggle,
onClick,
onContextMenu,
onTrailingClick,
trailingLabel,
}: RowProps) {
const api = useGalleryStoreApi();
const isMobile = useIsMobile();
const active = useGallery((s) => (view && !noActive ? s.view === view : false));
const handleClick = () => {
if (onClick) onClick();
else if (view) api.getState().setView(view);
if (isMobile) api.getState().setSidebar(false);
};
return (
<button
type="button"
className={[
'apg-sidebar__item',
indent ? 'apg-sidebar__child' : '',
active ? 'apg-sidebar__item--active' : '',
]
.filter(Boolean)
.join(' ')}
onClick={handleClick}
onContextMenu={onContextMenu}
aria-current={active ? 'page' : undefined}
>
{disclosure ? (
<span
className={['apg-sidebar__disclosure', open ? 'apg-sidebar__disclosure--open' : ''].join(
' ',
)}
onClick={(e) => {
e.stopPropagation();
onToggle?.();
}}
>
<Icon name="chevron-right" size={13} />
</span>
) : null}
<span className="apg-sidebar__item-icon">
<Icon name={icon} size={17} />
</span>
<span className="apg-sidebar__item-label">{label}</span>
{trailing ? (
onTrailingClick ? (
<span
className="apg-sidebar__item-trail apg-sidebar__item-trail--btn"
role="button"
tabIndex={0}
aria-label={trailingLabel}
title={trailingLabel}
onClick={(e) => {
e.stopPropagation();
onTrailingClick();
}}
>
<Icon name={trailing} size={14} />
</span>
) : (
<span className="apg-sidebar__item-trail">
<Icon name={trailing} size={14} />
</span>
)
) : null}
</button>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <div className="apg-sidebar__section-label">{children}</div>;
}
export function Sidebar() {
const api = useGalleryStoreApi();
const sidebarOpen = useGallery((s) => s.sidebarOpen);
const albums = useGallery((s) => s.albums);
// Recently Deleted lock state → closed lock when protected & not yet opened.
const locked = useGallery((s) => s.lock.hash !== null && !s.lockUnlocked);
const lockIcon: IconName = locked ? 'lock' : 'unlock';
const [sharingOpen, setSharingOpen] = useState(true);
const [albumsOpen, setAlbumsOpen] = useState(true);
const [objectsOpen, setObjectsOpen] = useState(true);
// Sidebar lock toggle: no password → set one; unlocked → re-lock; locked → go unlock.
const toggleLock = () => {
const s = api.getState();
if (!s.lock.hash) openSecuritySettings();
else if (s.lockUnlocked) s.relock();
else s.setView('recently-deleted');
};
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
// Auto "one album per detected object" (chair, table, person, car…), sorted by count.
const objectAlbums = albums.filter((a) => a.id.startsWith('sys:obj:'));
const albumMenu = (album: Album) => (e: React.MouseEvent) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, [
{
label: 'Rename Album',
icon: 'collections',
onClick: () =>
promptAlbumName('Rename Album', album.name, (name) =>
api.getState().renameAlbum(album.id, name),
),
},
{
label: 'Duplicate Album',
icon: 'duplicates',
onClick: () => api.getState().duplicateAlbum(album.id),
},
{ type: 'separator' },
{
label: 'Delete Album',
icon: 'trash',
danger: true,
onClick: () => api.getState().deleteAlbum(album.id),
},
]);
};
const newAlbum = () =>
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
api.getState().setView(`album:${id}` as ViewId);
});
return (
<nav
className={['apg-sidebar', sidebarOpen ? '' : 'apg-sidebar--collapsed'].join(' ')}
aria-label="Library navigation"
>
<Row icon="library" label="Library" view="library" />
<Row icon="collections" label="Collections" view="collections" />
<SectionLabel>Pinned</SectionLabel>
<Row icon="heart" label="Favourites" view="favourites" />
<Row icon="download" label="Recently Saved" view="recently-saved" />
<Row icon="map" label="Map" view="map" />
<Row icon="video" label="Videos" view="videos" />
<Row icon="screenshot" label="Screenshots" view="screenshots" />
<Row icon="document" label="Documents" view="sys:documents" />
<Row icon="person-circle" label="People & Pets" view="people" />
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
<div
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, [
{ label: 'New Album', icon: 'collections', onClick: newAlbum },
]);
}}
>
<SectionLabel>Albums</SectionLabel>
</div>
<Row
icon="collections"
label="All Albums"
view="albums"
disclosure
open={albumsOpen}
onToggle={() => {
setAlbumsOpen((v) => !v);
closeContextMenu();
}}
/>
{albumsOpen ? (
<>
<Row icon="plus" label="New Album" onClick={newAlbum} indent />
{userAlbums.map((a) => (
<Row
key={a.id}
icon={a.kind === 'folder' ? 'folder' : 'image'}
label={a.name}
view={a.id as ViewId}
indent
onContextMenu={albumMenu(a)}
/>
))}
</>
) : null}
{objectAlbums.length ? (
<>
<SectionLabel>Objects</SectionLabel>
<Row
icon="tag"
label="All Objects"
view="collections"
disclosure
open={objectsOpen}
onToggle={() => {
setObjectsOpen((v) => !v);
closeContextMenu();
}}
/>
{objectsOpen
? objectAlbums.map((a) => (
<Row
key={a.id}
icon={(a.icon as IconName) ?? 'tag'}
label={a.name}
view={a.id as ViewId}
indent
/>
))
: null}
</>
) : null}
<SectionLabel>Sharing</SectionLabel>
<Row
icon="people"
label="Shared Albums"
view="shared-albums"
disclosure
open={sharingOpen}
onToggle={() => {
setSharingOpen((v) => !v);
closeContextMenu();
}}
/>
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
<SectionLabel>Utilities</SectionLabel>
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
noActive
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
<Row icon="duplicates" label="Duplicates" view="duplicates" />
<Row icon="clock" label="Versions & Audit" view="versions" />
<Row icon="map" label="Map" view="map" noActive />
</nav>
);
}
@@ -0,0 +1,339 @@
'use client';
import { useState } from 'react';
import { GRID_ZOOM_STEPS } from '../constants';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { mediaForView } from '../store/selectors';
import type { GridFilter } from '../store/store';
import type { AlbumId, LibraryScale, MapMode, ViewId } from '../types';
import { confirmAction, openSecuritySettings, openShareModal } from './modals';
import { openUploadModal } from './UploadModal';
import { openContextMenu } from './ContextMenu';
import { Icon } from '../icons';
function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="apg-segmented" role="tablist">
{options.map((o) => (
<button
key={o.value}
type="button"
role="tab"
aria-selected={value === o.value}
className={['apg-segmented__item', value === o.value ? 'apg-segmented__item--active' : '']
.filter(Boolean)
.join(' ')}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
const GRID_VIEWS = new Set<string>([
'library',
'favourites',
'recently-saved',
'videos',
'screenshots',
'recently-deleted',
'duplicates',
'search',
]);
const FILTER_OPTIONS: Array<{ value: GridFilter; label: string }> = [
{ value: 'all', label: 'All Items' },
{ value: 'favourites', label: 'Favourites' },
{ value: 'edited', label: 'Edited' },
{ value: 'photos', label: 'Photos' },
{ value: 'videos', label: 'Videos' },
{ value: 'screenshots', label: 'Screenshots' },
{ value: 'not-in-album', label: 'Not in an Album' },
];
export function TopToolbar() {
const api = useGalleryStoreApi();
const view = useGallery((s) => s.view);
const libraryScale = useGallery((s) => s.libraryScale);
const mapMode = useGallery((s) => s.mapMode);
const gridFilter = useGallery((s) => s.gridFilter);
const searchQuery = useGallery((s) => s.searchQuery);
const zoomIndex = useGallery((s) => s.zoomIndex);
const features = useGallery((s) => s.config.features);
const theme = useGallery((s) => s.theme);
const aiStatus = useGallery((s) => s.aiStatus);
const infoOpen = useGallery((s) => s.infoOpen);
const selectionCount = useGallery((s) => s.selection.size);
const [searchExpanded, setSearchExpanded] = useState(false);
const isGrid = GRID_VIEWS.has(view) || view.startsWith('album:');
const isAlbumish = view !== 'library' && view !== 'collections';
// Uploads default into the album you're viewing (else Library).
const currentAlbumId = view.startsWith('album:') ? (view as unknown as AlbumId) : undefined;
const openFilterMenu = (e: React.MouseEvent) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
openContextMenu(
r.left,
r.bottom + 4,
FILTER_OPTIONS.map((o) => ({
label: o.label,
checked: gridFilter === o.value,
onClick: () => api.getState().setGridFilter(o.value),
})),
);
};
const openMoreMenu = (e: React.MouseEvent) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const state = api.getState();
const viewIds = mediaForView(state).map((m) => m.id);
const allSelected = viewIds.length > 0 && viewIds.every((id) => state.selection.has(id));
openContextMenu(r.left - 150, r.bottom + 4, [
allSelected
? {
label: 'Unselect All',
icon: 'close',
onClick: () => state.clearSelection(),
}
: {
label: 'Select All',
icon: 'check',
onClick: () => state.selectMany(viewIds),
},
...(features.import
? [{ label: 'Import…', icon: 'download' as const, onClick: () => openUploadModal(currentAlbumId) }]
: []),
{ type: 'separator' as const },
{ type: 'label' as const, label: 'Appearance' },
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
{
label: 'Semi-Dark (Glass)',
icon: 'adjust' as const,
checked: theme === 'semi-dark',
onClick: () => state.setTheme('semi-dark'),
},
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
{ type: 'separator' as const },
{
label: state.lock.hash ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
icon: state.lock.hash ? 'lock' : 'unlock',
onClick: () => openSecuritySettings(),
},
...(view === 'recently-deleted'
? [
{ type: 'separator' as const },
{
label: 'Delete All',
icon: 'trash' as const,
danger: true,
onClick: () =>
confirmAction({
title: 'Delete All Items?',
message: 'These items will be permanently deleted. This cannot be undone.',
confirmLabel: 'Delete All',
danger: true,
onConfirm: () => api.getState().emptyTrash(),
}),
},
]
: []),
]);
};
return (
<div className="apg-toolbar">
<button
type="button"
className="apg-iconbtn"
aria-label="Toggle sidebar"
onClick={() => api.getState().toggleSidebar()}
>
<Icon name="sidebar" />
</button>
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Back"
disabled={!isAlbumish}
onClick={() => api.getState().setView('library')}
>
<Icon name="chevron-left" size={18} />
</button>
{features.import ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Add photos"
onClick={() => openUploadModal(currentAlbumId)}
>
<Icon name="plus" />
</button>
) : null}
{features.camera ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Open camera"
onClick={() => api.getState().openCamera()}
>
<Icon name="camera" />
</button>
) : null}
{/* Center segmented control (context dependent). */}
<div className="apg-toolbar__center">
{view === 'library' || view === 'search' ? (
<Segmented<LibraryScale>
value={libraryScale}
onChange={(v) => api.getState().setLibraryScale(v)}
options={[
{ value: 'years', label: 'Years' },
{ value: 'months', label: 'Months' },
{ value: 'all', label: 'All Photos' },
]}
/>
) : view === 'map' ? (
<Segmented<MapMode>
value={mapMode}
onChange={(v) => api.getState().setMapMode(v)}
options={[
{ value: 'map', label: 'Map' },
{ value: 'satellite', label: 'Satellite' },
{ value: 'grid', label: 'Grid' },
]}
/>
) : null}
</div>
<div className="apg-toolbar__spacer" />
{isGrid ? (
<>
<button
type="button"
className="apg-iconbtn"
aria-label="Zoom out"
disabled={zoomIndex === 0}
onClick={() => api.getState().zoomOut()}
>
<Icon name="minus" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label="Zoom in"
disabled={zoomIndex === GRID_ZOOM_STEPS.length - 1}
onClick={() => api.getState().zoomIn()}
>
<Icon name="plus" />
</button>
<button type="button" className="apg-iconbtn" aria-label="Filter" onClick={openFilterMenu}>
<Icon name="filter" />
</button>
</>
) : null}
{features.sharing && (selectionCount > 0 || view.startsWith('album:')) ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Share"
onClick={() =>
openShareModal(
[...api.getState().selection],
view.startsWith('album:') ? view : undefined,
)
}
>
<Icon name="share" />
</button>
) : null}
<button type="button" className="apg-iconbtn" aria-label="More" onClick={openMoreMenu}>
<Icon name="ellipsis" />
</button>
<button
type="button"
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
aria-label="Info"
aria-pressed={infoOpen}
onClick={() => api.getState().toggleInfo()}
>
<Icon name="info" />
</button>
{aiStatus.running ? (
<span
className="apg-ai-status"
title="Analyzing your library with AI"
aria-live="polite"
>
<span className="apg-ai-spinner" aria-hidden="true" />
{aiStatus.done}/{aiStatus.total}
</span>
) : null}
<div
className={['apg-search', searchExpanded ? 'apg-search--expanded' : ''].join(' ')}
onClick={() => setSearchExpanded(true)}
>
<Icon name="search" size={16} />
<input
aria-label="Search"
placeholder="Search"
value={searchQuery}
onFocus={() => setSearchExpanded(true)}
onChange={(e) => {
const q = e.target.value;
api.getState().setSearch(q);
api.getState().setView((q ? 'search' : 'library') as ViewId);
}}
onBlur={() => setTimeout(() => setSearchExpanded(false), 120)}
/>
{searchExpanded && !searchQuery ? (
<div className="apg-search__recents" role="listbox">
<div className="apg-menu__label">Recents</div>
{(
[
['viewed', 'Recently Viewed'],
['edited', 'Recently Edited'],
['added', 'Recently Added'],
] as Array<['viewed' | 'edited' | 'added', string]>
).map(([key, label]) => (
<button
key={key}
type="button"
className="apg-menu__item"
onMouseDown={(e) => {
e.preventDefault();
api.getState().setSearchPreset(key);
setSearchExpanded(false);
}}
>
<span className="apg-menu__icon">
<Icon name="clock" size={15} />
</span>
{label}
</button>
))}
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
'use client';
import { useRef, useState } from 'react';
import { Icon } from '../icons';
import { isAcceptedMediaFile } from '../lib/media';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { AlbumId } from '../types';
import { closeModal, openModal } from './Modal';
/** Use the SAME allow-list the import pipeline enforces (mediaFromFile), so the
* "N files ready" count matches what will actually be accepted no silent skips. */
const acceptFile = (f: File) => isAcceptedMediaFile(f);
function UploadModal({ defaultAlbumId }: { defaultAlbumId?: AlbumId }) {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums.filter((a) => a.kind === 'user' || a.kind === 'folder'));
const [files, setFiles] = useState<File[]>([]);
const [rejected, setRejected] = useState(0);
const [album, setAlbum] = useState<string>(defaultAlbumId ?? '');
const [busy, setBusy] = useState(false);
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const add = (list: FileList | File[] | null) => {
if (!list) return;
const all = Array.from(list);
const ok = all.filter(acceptFile);
setRejected((r) => r + (all.length - ok.length));
if (ok.length) setFiles((prev) => [...prev, ...ok]);
};
// Open the native OS file picker. A real, explicit control triggering input.click()
// is the most reliable cross-browser way to open the file dialog.
const browse = () => inputRef.current?.click();
const upload = async () => {
if (!files.length) return;
setBusy(true);
try {
await api.getState().importFiles(files, album || undefined);
closeModal();
} finally {
setBusy(false);
}
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Add photos and videos" style={{ minWidth: 400 }}>
<div className="apg-modal__title">Add Photos &amp; Videos</div>
<button
type="button"
className={['apg-upload__drop', drag ? 'apg-upload__drop--over' : ''].join(' ')}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
add(e.dataTransfer.files);
}}
onClick={browse}
>
<Icon name="download" size={28} />
<div style={{ fontWeight: 600 }}>Drag &amp; drop here, or click to browse</div>
<div style={{ fontSize: 12, color: 'var(--apg-text-secondary)' }}>
Images &amp; videos only · multiple allowed
</div>
{files.length ? (
<div style={{ fontSize: 13, marginTop: 6, color: 'var(--apg-accent)' }}>
{files.length} file{files.length === 1 ? '' : 's'} ready
</div>
) : null}
{rejected ? (
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--apg-danger)' }}>
{rejected} unsupported file{rejected === 1 ? '' : 's'} skipped
</div>
) : null}
</button>
{/* Explicit, always-visible browse control (the dropzone click can be flaky on
some setups; a real button reliably opens the OS file dialog). */}
<button
type="button"
className="apg-btn"
style={{ width: '100%', marginTop: 8, cursor: 'pointer' }}
onClick={browse}
>
Choose files
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
// visually hidden (not the `hidden` attribute — some browsers refuse a
// programmatic .click() on a display:none file input).
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
onChange={(e) => {
add(e.target.files);
// Reset so choosing the SAME file again still fires onChange.
e.target.value = '';
}}
/>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 12, fontSize: 12 }}>
<span style={{ color: 'var(--apg-text-secondary)' }}>Add to album</span>
<select className="apg-modal__input" value={album} onChange={(e) => setAlbum(e.target.value)}>
<option value="">Library only</option>
{albums.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal} disabled={busy}>
Cancel
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => void upload()}
disabled={busy || files.length === 0}
>
{busy ? 'Uploading…' : `Upload${files.length ? ` ${files.length}` : ''}`}
</button>
</div>
</div>
);
}
/** Open the upload modal, defaulting the album to the currently-open one. */
export function openUploadModal(defaultAlbumId?: AlbumId) {
openModal(<UploadModal defaultAlbumId={defaultAlbumId} />);
}
@@ -0,0 +1,248 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../icons';
const fmt = (s: number) => {
if (!Number.isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
};
interface VideoPlayerProps {
src: string;
poster?: string;
/** CSS filter string applied to the video (for edited clips). */
filter?: string;
autoPlay?: boolean;
}
/**
* Custom macOS-style video player: a glass transport bar (play/pause, scrubber
* with buffered + played tracks, time, volume, mute, PiP, fullscreen), a centre
* play affordance, auto-hiding controls while playing, and keyboard shortcuts.
* Replaces the browser's native controls for a consistent, on-brand look.
*/
export function VideoPlayer({ src, poster, filter, autoPlay = true }: VideoPlayerProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [controlsShown, setControlsShown] = useState(true);
const v = () => videoRef.current;
const togglePlay = () => {
const el = v();
if (!el) return;
if (el.paused) void el.play();
else el.pause();
};
const seek = (t: number) => {
const el = v();
if (el) el.currentTime = Math.max(0, Math.min(duration || 0, t));
};
const toggleMute = () => {
const el = v();
if (!el) return;
el.muted = !el.muted;
setMuted(el.muted);
};
const changeVolume = (val: number) => {
const el = v();
if (!el) return;
el.volume = val;
el.muted = val === 0;
setVolume(val);
setMuted(val === 0);
};
const toggleFullscreen = () => {
const wrap = wrapRef.current;
if (!wrap) return;
if (document.fullscreenElement) void document.exitFullscreen();
else void wrap.requestFullscreen?.();
};
const togglePip = () => {
const el = v() as HTMLVideoElement & { requestPictureInPicture?: () => Promise<unknown> };
if (!el) return;
if (document.pictureInPictureElement) void document.exitPictureInPicture();
else void el.requestPictureInPicture?.().catch(() => {});
};
// Auto-hide controls while playing; always show when paused / on activity.
const nudge = () => {
setControlsShown(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => {
if (!v()?.paused) setControlsShown(false);
}, 2600);
};
useEffect(() => {
const onFs = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', onFs);
return () => {
document.removeEventListener('fullscreenchange', onFs);
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []);
const onKeyDown = (e: React.KeyboardEvent) => {
const el = v();
if (!el) return;
// Keys the player owns. For these, fully stop the native event so the
// Lightbox's window-level keydown listener (prev/next/Escape) doesn't ALSO
// fire — React's stopPropagation alone can't stop a separate window listener.
const owned = [' ', 'k', 'ArrowLeft', 'ArrowRight', 'm', 'f'];
if (owned.includes(e.key)) {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation?.();
}
switch (e.key) {
case ' ':
case 'k':
togglePlay();
break;
case 'ArrowLeft':
seek(el.currentTime - 5);
break;
case 'ArrowRight':
seek(el.currentTime + 5);
break;
case 'm':
toggleMute();
break;
case 'f':
toggleFullscreen();
break;
}
nudge();
};
const pct = duration ? (time / duration) * 100 : 0;
const bufPct = duration ? (buffered / duration) * 100 : 0;
return (
<div
ref={wrapRef}
className={['apg-vp', controlsShown ? '' : 'apg-vp--idle', fullscreen ? 'apg-vp--fs' : ''].join(' ')}
onMouseMove={nudge}
onKeyDown={onKeyDown}
tabIndex={0}
role="group"
aria-label="Video player"
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
className="apg-vp__video"
src={src}
poster={poster}
autoPlay={autoPlay}
playsInline
style={filter ? { filter } : undefined}
onClick={togglePlay}
onPlay={() => {
setPlaying(true);
nudge();
}}
onPause={() => {
setPlaying(false);
setControlsShown(true);
}}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
onTimeUpdate={(e) => {
const el = e.currentTarget;
setTime(el.currentTime);
try {
if (el.buffered.length) setBuffered(el.buffered.end(el.buffered.length - 1));
} catch {
/* ignore */
}
}}
onVolumeChange={(e) => {
setVolume(e.currentTarget.volume);
setMuted(e.currentTarget.muted);
}}
/>
{!playing ? (
<button type="button" className="apg-vp__center" aria-label="Play" onClick={togglePlay}>
<Icon name="play" size={34} />
</button>
) : null}
<div className="apg-vp__controls" onClick={(e) => e.stopPropagation()}>
<button type="button" className="apg-vp__btn" aria-label={playing ? 'Pause' : 'Play'} onClick={togglePlay}>
<Icon name={playing ? 'pause' : 'play'} size={18} />
</button>
<span className="apg-vp__time">{fmt(time)}</span>
<div
className="apg-vp__scrub"
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(time)}
onPointerDown={(e) => {
if (!duration) return; // metadata not loaded yet — nothing to seek
const bar = e.currentTarget;
const move = (clientX: number) => {
const r = bar.getBoundingClientRect();
seek(((clientX - r.left) / r.width) * duration);
};
move(e.clientX);
bar.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => move(ev.clientX);
// Release on BOTH pointerup and pointercancel (touch interruptions,
// system gestures) so capture/listeners never leak.
const onUp = () => {
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
}}
>
<span className="apg-vp__track" />
<span className="apg-vp__buffered" style={{ width: `${bufPct}%` }} />
<span className="apg-vp__played" style={{ width: `${pct}%` }}>
<span className="apg-vp__knob" />
</span>
</div>
<span className="apg-vp__time">{fmt(duration)}</span>
<button type="button" className="apg-vp__btn" aria-label={muted ? 'Unmute' : 'Mute'} onClick={toggleMute}>
<Icon name={muted || volume === 0 ? 'mute' : 'volume'} size={18} />
</button>
<input
className="apg-vp__vol"
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
aria-label="Volume"
onChange={(e) => changeVolume(Number(e.target.value))}
/>
<button type="button" className="apg-vp__btn" aria-label="Picture in picture" onClick={togglePip}>
<Icon name="pip" size={18} />
</button>
<button type="button" className="apg-vp__btn" aria-label="Fullscreen" onClick={toggleFullscreen}>
<Icon name="expand" size={18} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
'use client';
import { useGallery } from '../store/context';
import { CollectionsView } from './views/CollectionsView';
import {
ActivityView,
AlbumView,
DuplicatesView,
GridScreen,
SharedAlbumsView,
} from './views/GridScreens';
import { LibraryView } from './views/LibraryView';
import { MapView } from './views/MapView';
import { PeopleView } from './views/PeopleView';
import { AlbumsOverview } from './views/ProjectsView';
import { RecentlyDeletedView } from './views/RecentlyDeletedView';
import { VersionsView } from './views/VersionsView';
export function ViewRouter() {
const view = useGallery((s) => s.view);
switch (view) {
case 'collections':
return <CollectionsView />;
case 'map':
return <MapView />;
case 'people':
return <PeopleView />;
case 'recently-deleted':
return <RecentlyDeletedView />;
case 'albums':
return <AlbumsOverview />;
case 'shared-albums':
return <SharedAlbumsView />;
case 'activity':
return <ActivityView />;
case 'duplicates':
return <DuplicatesView />;
case 'versions':
return <VersionsView />;
case 'library':
case 'search':
return <LibraryView />;
default:
if (view.startsWith('album:') || view.startsWith('sys:')) return <AlbumView />;
return <GridScreen />;
}
}
@@ -0,0 +1,12 @@
'use client';
import { createContext, useContext } from 'react';
import type { AIProvider } from '../ai/types';
/** Makes the configured AIProvider available to UI (e.g. the editor's AI tools). */
export const AIProviderContext = createContext<AIProvider | null>(null);
export function useAIProvider(): AIProvider | null {
return useContext(AIProviderContext);
}
@@ -0,0 +1,282 @@
'use client';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import type { Annotation, AnnotationShape } from '../../types';
export type AnnotationTool = AnnotationShape | 'select';
interface AnnotationsProps {
annotations: Annotation[];
editable?: boolean;
tool?: AnnotationTool;
color?: string;
strokeWidth?: number;
onChange?: (next: Annotation[]) => void;
}
/**
* SVG markup overlay. Renders (and, when editable, lets you draw) rectangles,
* ellipses, lines, arrows, double-arrows with a centered measurement label,
* text and freehand strokes. Geometry is stored normalized (0..1) so it scales
* with the image. Read-only mode (editable=false) ignores pointer events.
*/
export function Annotations({
annotations,
editable = false,
tool = 'select',
color = '#ff3b30',
strokeWidth = 3,
onChange,
}: AnnotationsProps) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
const [draft, setDraft] = useState<Annotation | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const drawing = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const norm = (e: React.PointerEvent) => {
const rect = ref.current!.getBoundingClientRect();
return {
x: clamp01((e.clientX - rect.left) / rect.width),
y: clamp01((e.clientY - rect.top) / rect.height),
};
};
const commit = (next: Annotation[]) => onChange?.(next);
const onPointerDown = (e: React.PointerEvent) => {
if (!editable || tool === 'select') return;
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
const p = norm(e);
drawing.current = true;
const base: Annotation = {
id: nanoid(8),
shape: tool,
color,
strokeWidth,
x1: p.x,
y1: p.y,
x2: p.x,
y2: p.y,
};
if (tool === 'freehand') base.points = [{ x: p.x, y: p.y }];
if (tool === 'text') {
// Place immediately and open the inline editor.
base.text = '';
commit([...annotations, base]);
setEditingId(base.id);
drawing.current = false;
return;
}
setDraft(base);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!drawing.current || !draft) return;
const p = norm(e);
setDraft((d) =>
d
? {
...d,
x2: p.x,
y2: p.y,
points: d.shape === 'freehand' ? [...(d.points ?? []), { x: p.x, y: p.y }] : d.points,
}
: d,
);
};
const onPointerUp = () => {
if (!drawing.current || !draft) return;
drawing.current = false;
const moved = Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) > 0.01 || draft.shape === 'freehand';
if (moved) {
commit([...annotations, draft]);
if (draft.shape === 'double-arrow') {
draft.text = '';
setEditingId(draft.id);
}
}
setDraft(null);
};
const updateText = (id: string, text: string) =>
commit(annotations.map((a) => (a.id === id ? { ...a, text } : a)));
const finishEditing = (id: string) => {
const a = annotations.find((x) => x.id === id);
if (a && a.shape === 'text' && !a.text?.trim()) commit(annotations.filter((x) => x.id !== id));
setEditingId(null);
};
const all = draft ? [...annotations, draft] : annotations;
const editingAnn = editingId ? annotations.find((a) => a.id === editingId) : null;
return (
<div
ref={ref}
style={{
position: 'absolute',
inset: 0,
cursor: editable && tool !== 'select' ? 'crosshair' : 'default',
pointerEvents: editable ? 'auto' : 'none',
touchAction: 'none',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{size.w > 0 ? (
<svg width={size.w} height={size.h} style={{ position: 'absolute', inset: 0 }}>
{all.map((a) => (
<Shape key={a.id} a={a} w={size.w} h={size.h} />
))}
</svg>
) : null}
{editable && editingAnn ? (
<input
autoFocus
value={editingAnn.text ?? ''}
onChange={(e) => updateText(editingAnn.id, e.target.value)}
onBlur={() => finishEditing(editingAnn.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Escape') finishEditing(editingAnn.id);
}}
placeholder={editingAnn.shape === 'double-arrow' ? 'e.g. 12 cm' : 'Text'}
style={{
position: 'absolute',
left: `${((editingAnn.x1 + (editingAnn.shape === 'double-arrow' ? editingAnn.x2 : editingAnn.x1)) / 2) * 100}%`,
top: `${((editingAnn.y1 + (editingAnn.shape === 'double-arrow' ? editingAnn.y2 : editingAnn.y1)) / 2) * 100}%`,
transform: 'translate(-50%, -50%)',
font: '600 14px system-ui, sans-serif',
color: editingAnn.color,
background: 'rgba(255,255,255,0.95)',
border: `2px solid ${editingAnn.color}`,
borderRadius: 6,
padding: '2px 6px',
minWidth: 60,
outline: 'none',
zIndex: 2,
}}
/>
) : null}
</div>
);
}
function Shape({ a, w, h }: { a: Annotation; w: number; h: number }) {
const x1 = a.x1 * w;
const y1 = a.y1 * h;
const x2 = a.x2 * w;
const y2 = a.y2 * h;
const common = { stroke: a.color, strokeWidth: a.strokeWidth, fill: 'none' as const };
const headSize = 9 + a.strokeWidth * 2;
switch (a.shape) {
case 'rect':
return (
<rect
x={Math.min(x1, x2)}
y={Math.min(y1, y2)}
width={Math.abs(x2 - x1)}
height={Math.abs(y2 - y1)}
rx={4}
{...common}
/>
);
case 'ellipse':
return (
<ellipse
cx={(x1 + x2) / 2}
cy={(y1 + y2) / 2}
rx={Math.abs(x2 - x1) / 2}
ry={Math.abs(y2 - y1) / 2}
{...common}
/>
);
case 'line':
return <line x1={x1} y1={y1} x2={x2} y2={y2} {...common} strokeLinecap="round" />;
case 'arrow':
return (
<g>
<line x1={x1} y1={y1} x2={x2} y2={y2} {...common} strokeLinecap="round" />
<polygon points={arrowHead(x1, y1, x2, y2, headSize)} fill={a.color} />
</g>
);
case 'double-arrow':
return (
<g>
<line x1={x1} y1={y1} x2={x2} y2={y2} {...common} strokeLinecap="round" />
<polygon points={arrowHead(x2, y2, x1, y1, headSize)} fill={a.color} />
<polygon points={arrowHead(x1, y1, x2, y2, headSize)} fill={a.color} />
{a.text ? (
<text
x={(x1 + x2) / 2}
y={(y1 + y2) / 2 - 6}
fill={a.color}
stroke="#fff"
strokeWidth={3}
paintOrder="stroke"
textAnchor="middle"
style={{ font: '700 15px system-ui, sans-serif' }}
>
{a.text}
</text>
) : null}
</g>
);
case 'text':
return a.text ? (
<text
x={x1}
y={y1}
fill={a.color}
stroke="#fff"
strokeWidth={3}
paintOrder="stroke"
dominantBaseline="middle"
style={{ font: '700 16px system-ui, sans-serif' }}
>
{a.text}
</text>
) : null;
case 'freehand':
return (
<polyline
points={(a.points ?? []).map((p) => `${p.x * w},${p.y * h}`).join(' ')}
{...common}
strokeLinecap="round"
strokeLinejoin="round"
/>
);
default:
return null;
}
}
/** Triangle polygon for an arrowhead pointing from (fx,fy) toward (tx,ty). */
function arrowHead(fx: number, fy: number, tx: number, ty: number, size: number): string {
const ang = Math.atan2(ty - fy, tx - fx);
const a1 = ang + Math.PI - 0.45;
const a2 = ang + Math.PI + 0.45;
const p1 = `${tx},${ty}`;
const p2 = `${tx + size * Math.cos(a1)},${ty + size * Math.sin(a1)}`;
const p3 = `${tx + size * Math.cos(a2)},${ty + size * Math.sin(a2)}`;
return `${p1} ${p2} ${p3}`;
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
@@ -0,0 +1,173 @@
'use client';
import { useEffect, useRef, useState } from 'react';
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
}
type Handle = 'nw' | 'ne' | 'sw' | 'se' | 'move';
/**
* Interactive crop overlay. Geometry is normalized (0..1) relative to the image
* box. When `ratio` is set (pixel w:h), resizing keeps that aspect so a 1:1
* box stays square even as you drag. `ratio = null` is free-form.
*/
export function CropBox({
rect,
ratio,
onChange,
}: {
rect: CropRect;
ratio: number | null;
onChange: (r: CropRect) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ w: 0, h: 0 });
const drag = useRef<{ handle: Handle; startRect: CropRect; startX: number; startY: number } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => setBox({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Normalized aspect (width:height in 0..1 space) for the target pixel ratio.
const normAspect = ratio && box.w && box.h ? ratio * (box.h / box.w) : null;
const pointer = (e: React.PointerEvent) => {
const r = ref.current!.getBoundingClientRect();
return { x: clamp01((e.clientX - r.left) / r.width), y: clamp01((e.clientY - r.top) / r.height) };
};
const onDown = (handle: Handle) => (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
(e.target as Element).setPointerCapture?.(e.pointerId);
const p = pointer(e);
drag.current = { handle, startRect: rect, startX: p.x, startY: p.y };
};
const onMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const p = pointer(e);
const dx = p.x - d.startX;
const dy = p.y - d.startY;
if (d.handle === 'move') {
const nx = clamp(d.startRect.x + dx, 0, 1 - d.startRect.width);
const ny = clamp(d.startRect.y + dy, 0, 1 - d.startRect.height);
onChange({ ...d.startRect, x: nx, y: ny });
return;
}
// Corner resize: the opposite corner stays anchored.
const s = d.startRect;
const anchor = {
x: d.handle === 'nw' || d.handle === 'sw' ? s.x + s.width : s.x,
y: d.handle === 'nw' || d.handle === 'ne' ? s.y + s.height : s.y,
};
let cx = clamp01(p.x);
let cy = clamp01(p.y);
let w = Math.abs(cx - anchor.x);
let h = Math.abs(cy - anchor.y);
if (normAspect) {
// Enforce aspect: derive the dimension pair from the dominant drag axis.
if (w / (normAspect || 1) > h) h = w / normAspect;
else w = h * normAspect;
// Re-clamp so the box stays inside [0,1] without breaking aspect.
const dirX = cx >= anchor.x ? 1 : -1;
const dirY = cy >= anchor.y ? 1 : -1;
const maxW = dirX > 0 ? 1 - anchor.x : anchor.x;
const maxH = dirY > 0 ? 1 - anchor.y : anchor.y;
if (w > maxW) {
w = maxW;
h = w / normAspect;
}
if (h > maxH) {
h = maxH;
w = h * normAspect;
}
cx = anchor.x + dirX * w;
cy = anchor.y + dirY * h;
}
const x = Math.min(cx, anchor.x);
const y = Math.min(cy, anchor.y);
if (w < 0.04 || h < 0.04) return; // ignore degenerate boxes
onChange({ x, y, width: w, height: h });
};
const onUp = () => {
drag.current = null;
};
const pct = (v: number) => `${v * 100}%`;
return (
<div
ref={ref}
style={{ position: 'absolute', inset: 0, touchAction: 'none' }}
onPointerMove={onMove}
onPointerUp={onUp}
>
{/* Darken outside the crop region (4 masks). */}
<div style={maskStyle({ left: 0, top: 0, width: '100%', height: pct(rect.y) })} />
<div style={maskStyle({ left: 0, top: pct(rect.y + rect.height), width: '100%', bottom: 0 })} />
<div style={maskStyle({ left: 0, top: pct(rect.y), width: pct(rect.x), height: pct(rect.height) })} />
<div style={maskStyle({ left: pct(rect.x + rect.width), top: pct(rect.y), right: 0, height: pct(rect.height) })} />
{/* Crop rectangle */}
<div
onPointerDown={onDown('move')}
style={{
position: 'absolute',
left: pct(rect.x),
top: pct(rect.y),
width: pct(rect.width),
height: pct(rect.height),
border: '1px solid rgba(255,255,255,0.9)',
boxShadow: '0 0 0 1px rgba(0,0,0,0.3)',
cursor: 'move',
backgroundImage:
'linear-gradient(rgba(255,255,255,0.35) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.35) 1px, transparent 1px)',
backgroundSize: '33.33% 33.33%',
}}
>
{(['nw', 'ne', 'sw', 'se'] as Handle[]).map((h) => (
<div key={h} onPointerDown={onDown(h)} style={handleStyle(h)} />
))}
</div>
</div>
);
}
function maskStyle(pos: Record<string, string | number>): React.CSSProperties {
return { position: 'absolute', background: 'rgba(0,0,0,0.55)', pointerEvents: 'none', ...pos };
}
function handleStyle(h: Handle): React.CSSProperties {
const base: React.CSSProperties = {
position: 'absolute',
width: 16,
height: 16,
background: '#fff',
borderRadius: '50%',
boxShadow: '0 1px 3px rgba(0,0,0,0.5)',
};
const off = -8;
if (h === 'nw') return { ...base, left: off, top: off, cursor: 'nwse-resize' };
if (h === 'ne') return { ...base, right: off, top: off, cursor: 'nesw-resize' };
if (h === 'sw') return { ...base, left: off, bottom: off, cursor: 'nesw-resize' };
return { ...base, right: off, bottom: off, cursor: 'nwse-resize' };
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const clamp01 = (v: number) => clamp(v, 0, 1);
@@ -0,0 +1,670 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import type { GenerativeEditOp } from '../../ai/types';
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../../constants';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { editFilterCss, editTransformCss } from '../../lib/edits';
import { summarizeEdits } from '../../lib/versions';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import type { Annotation, EditAdjustments, EditState, MediaId, MediaItem } from '../../types';
import { bakeEdits, hasGeometry } from '../../lib/bake';
import { addToAlbumPicker, confirmAction } from '../modals';
import { useAIProvider } from '../aiContext';
import { Annotations, type AnnotationTool } from './Annotations';
import { CropBox, type CropRect } from './CropBox';
const RATIOS: Array<{ label: string; value: number | null }> = [
{ label: 'Free', value: null },
{ label: 'Square', value: 1 },
{ label: '4:3', value: 4 / 3 },
{ label: '3:4', value: 3 / 4 },
{ label: '16:9', value: 16 / 9 },
{ label: '9:16', value: 9 / 16 },
];
function centeredCrop(ratio: number | null, imgAspect: number): CropRect {
if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 };
let w = 1;
let h = imgAspect / ratio;
if (h > 1) {
h = 1;
w = ratio / imgAspect;
}
return { x: (1 - w) / 2, y: (1 - h) / 2, width: w, height: h };
}
type Tab = 'adjust' | 'filters' | 'crop' | 'markup' | 'ai';
const ANN_TOOLS: Array<{ tool: AnnotationTool; label: string; icon: IconName }> = [
{ tool: 'select', label: 'Select', icon: 'check' },
{ tool: 'rect', label: 'Rectangle', icon: 'aspect' },
{ tool: 'ellipse', label: 'Oval', icon: 'filters' },
{ tool: 'line', label: 'Line', icon: 'minus' },
{ tool: 'arrow', label: 'Arrow', icon: 'chevron-right' },
{ tool: 'double-arrow', label: 'Measure', icon: 'crop' },
{ tool: 'text', label: 'Text', icon: 'tag' },
{ tool: 'freehand', label: 'Draw', icon: 'wand' },
];
const ANN_COLORS = ['#ff3b30', '#ff9f0a', '#ffd60a', '#34c759', '#0a84ff', '#bf5af2', '#ffffff', '#000000'];
const ADJUST_FIELDS: Array<{ key: keyof EditAdjustments; label: string }> = [
{ key: 'exposure', label: 'Exposure' },
{ key: 'brilliance', label: 'Brilliance' },
{ key: 'highlights', label: 'Highlights' },
{ key: 'shadows', label: 'Shadows' },
{ key: 'contrast', label: 'Contrast' },
{ key: 'brightness', label: 'Brightness' },
{ key: 'blackPoint', label: 'Black Point' },
{ key: 'saturation', label: 'Saturation' },
{ key: 'vibrance', label: 'Vibrance' },
{ key: 'warmth', label: 'Warmth' },
{ key: 'tint', label: 'Tint' },
{ key: 'sharpness', label: 'Sharpness' },
{ key: 'definition', label: 'Definition' },
{ key: 'vignette', label: 'Vignette' },
];
const AI_OPS: Array<{ label: string; op: GenerativeEditOp; icon: 'wand' | 'image' | 'crop' }> = [
{ label: 'Remove Background', op: { type: 'remove-background' }, icon: 'wand' },
{ label: 'Restore & Enhance', op: { type: 'restore' }, icon: 'wand' },
{ label: 'Colorize', op: { type: 'colorize' }, icon: 'wand' },
{ label: 'Replace Sky', op: { type: 'replace-sky' }, icon: 'image' },
];
export function PhotoEditor() {
const api = useGalleryStoreApi();
const editorId = useGallery((s) => s.editorId);
const media = useGallery((s) => s.media);
const provider = useAIProvider();
const aiAvailable = Boolean(provider?.generativeEdit);
const item = media.find((m) => m.id === editorId) ?? null;
const [tab, setTab] = useState<Tab>('adjust');
const [edits, setEdits] = useState<EditState>({ adjustments: {} });
const [aiBusy, setAiBusy] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [aiResultUrl, setAiResultUrl] = useState<string | null>(null);
const [aiPrompt, setAiPrompt] = useState('');
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState<string>('#ff3b30');
const [cropRatio, setCropRatio] = useState<number | null>(null);
const [baking, setBaking] = useState(false);
const aiBlobRef = useRef<Blob | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape routes through the same dirty-check as the Cancel button (assigned below).
const cancelRef = useRef<() => void>(() => api.getState().closeEditor());
useFocusTrap(dialogRef, Boolean(item), () => cancelRef.current());
// Reset state when the editor opens on a new item.
useEffect(() => {
if (item) {
setEdits(item.edits ?? { adjustments: {} });
setTab('adjust');
setAiError(null);
setAiPrompt('');
setCropRatio(null);
aiBlobRef.current = null;
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}
}, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Videos are handled by the dedicated VideoEditor.
if (!item || item.kind === 'video') return null;
const adj = { ...ZERO_ADJUSTMENTS, ...edits.adjustments };
const setAdj = (key: keyof EditAdjustments, value: number) =>
setEdits((e) => ({ ...e, adjustments: { ...e.adjustments, [key]: value } }));
const annotations = edits.annotations ?? [];
const setAnnotations = (next: Annotation[]) => setEdits((e) => ({ ...e, annotations: next }));
const imgAspect = item.width / Math.max(1, item.height);
const cropRect: CropRect = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
const setCrop = (r: CropRect) => setEdits((e) => ({ ...e, crop: r }));
const pickRatio = (value: number | null) => {
setCropRatio(value);
setCrop(centeredCrop(value, imgAspect));
};
const filterCss = aiResultUrl ? undefined : editFilterCss(edits);
// Apply rotate / straighten (tilt) / flip live in ALL tabs (incl. Crop) so the
// user sees them immediately; the crop box overlays the transformed frame.
const transformCss = aiResultUrl ? undefined : editTransformCss(edits);
const runAI = async (op: GenerativeEditOp) => {
if (!provider?.generativeEdit) return;
setAiBusy(true);
setAiError(null);
try {
const img = await loadCrossOriginImage(item.src);
const blob = await provider.generativeEdit(item, img, op);
aiBlobRef.current = blob;
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch (err) {
setAiError(err instanceof Error ? err.message : 'AI edit failed. Please try again.');
} finally {
setAiBusy(false);
}
};
const clearAI = () => {
aiBlobRef.current = null;
setAiError(null);
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
/** True when there are unsaved changes worth confirming before discard. */
const isDirty = () =>
aiBlobRef.current !== null ||
!!edits.filter ||
hasGeometry(edits) ||
(edits.annotations?.length ?? 0) > 0 ||
Object.keys(edits.adjustments ?? {}).length > 0;
/**
* Build the result patch for a target id. Uploads a new blob (AI / baked
* geometry) under that id when needed; returns the MediaItem patch to apply.
*/
const buildPatch = async (targetId: MediaId): Promise<Partial<MediaItem>> => {
if (aiBlobRef.current) {
const uploaded = await api.getState().uploadBlob(targetId, aiBlobRef.current);
const src = uploaded ?? (await blobToDataUrl(aiBlobRef.current));
return { src, thumbnail: undefined, edits: undefined, editedAt: Date.now(), analyzedAt: undefined };
}
if (hasGeometry(edits)) {
const img = await loadCrossOriginImage(item.src);
const { blob, width, height, annotations: remapped } = await bakeEdits(img, edits);
const uploaded = await api.getState().uploadBlob(targetId, blob);
const src = uploaded ?? (await blobToDataUrl(blob));
return {
src,
thumbnail: undefined,
width,
height,
edits: remapped.length ? { adjustments: {}, annotations: remapped } : undefined,
editedAt: Date.now(),
analyzedAt: undefined,
};
}
// Non-destructive (filter/adjust/markup only): keep the same src, store edits.
return { edits, editedAt: Date.now() };
};
const save = async (asCopy: boolean) => {
setBaking(true);
// Human-readable audit log for this edit (same for copy + overwrite).
const changes = aiBlobRef.current
? ['AI edit', ...summarizeEdits(edits).filter((c) => c !== 'Edited')]
: summarizeEdits(edits);
try {
if (asCopy) {
// Upload (if any) under a fresh id, create the copy, then let the user file it.
const copyId = nanoid(10) as MediaId;
const patch = await buildPatch(copyId);
const newId = api.getState().duplicateWithEdits(item.id, { ...patch, id: copyId }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
const patch = await buildPatch(item.id);
// Non-destructive save: preserve the original as v1 and append a new
// version with an audit log of what changed instead of overwriting.
api.getState().addVersion(item.id, patch, changes);
api.getState().closeEditor();
}
} catch {
// Bake/upload failed (e.g. cross-origin) — still record a version so history
// is never lost. Copies still get their own 2-entry history via the fallback.
if (asCopy) {
const copyId = nanoid(10) as MediaId;
const newId = api
.getState()
.duplicateWithEdits(item.id, { id: copyId, edits, editedAt: Date.now() }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
api.getState().addVersion(item.id, { edits, editedAt: Date.now() }, changes);
api.getState().closeEditor();
}
} finally {
setBaking(false);
}
};
const cancel = () => {
if (!isDirty()) {
api.getState().closeEditor();
return;
}
confirmAction({
title: 'Discard changes?',
message: 'Your edits to this photo have not been saved.',
confirmLabel: 'Discard',
danger: true,
onConfirm: () => api.getState().closeEditor(),
});
};
cancelRef.current = cancel;
const revert = () => {
clearAI();
setEdits({ adjustments: {} });
};
const tabs: Tab[] = aiAvailable
? ['adjust', 'filters', 'crop', 'markup', 'ai']
: ['adjust', 'filters', 'crop', 'markup'];
const tabLabel = (t: Tab) =>
t === 'adjust' ? 'Adjust' : t === 'filters' ? 'Filters' : t === 'crop' ? 'Crop' : t === 'markup' ? 'Markup' : 'AI';
const previewSrc = aiResultUrl ?? item.src;
return (
<AnimatePresence>
<motion.div
ref={dialogRef}
className="apg-editor"
role="dialog"
aria-modal="true"
aria-label={`Edit ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
>
<div className="apg-editor__bar">
<button
type="button"
className="apg-iconbtn"
aria-label="Cancel"
onClick={cancel}
>
<Icon name="close" />
</button>
<div style={{ fontWeight: 600 }}>Edit · {item.name}</div>
<div style={{ flex: 1 }} />
<button type="button" className="apg-btn" onClick={revert} disabled={baking}>
Revert
</button>
<button
type="button"
className="apg-btn"
onClick={() => void save(true)}
disabled={baking || aiBusy}
title="Save the result as a new photo (you choose the album)"
>
Save as Copy
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => void save(false)}
disabled={baking || aiBusy}
title="Overwrite this photo with your edits"
>
{baking ? 'Saving…' : 'Save'}
</button>
</div>
<div className="apg-editor__body">
<div className="apg-editor__canvaswrap">
<div style={{ position: 'relative', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={previewSrc}
alt={item.name}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '78vh',
borderRadius: 4,
filter: filterCss,
transform: transformCss,
transition: 'filter 0.08s linear',
// Checkerboard shows through transparent (background-removed) results.
background: aiResultUrl
? 'repeating-conic-gradient(#3a3a3c 0% 25%, #2a2a2c 0% 50%) 50% / 20px 20px'
: undefined,
}}
/>
{tab !== 'crop' ? (
<Annotations
annotations={annotations}
editable={tab === 'markup'}
tool={annTool}
color={annColor}
onChange={setAnnotations}
/>
) : null}
{tab === 'crop' ? (
<CropBox rect={cropRect} ratio={cropRatio} onChange={setCrop} />
) : null}
{!aiResultUrl && tab !== 'crop' && adj.vignette > 0 ? (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: 4,
boxShadow: `inset 0 0 ${60 + adj.vignette * 140}px rgba(0,0,0,${(
adj.vignette * 0.8
).toFixed(2)})`,
}}
/>
) : null}
{aiBusy ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,0,0,0.45)',
borderRadius: 4,
color: '#fff',
gap: 10,
flexDirection: 'column',
}}
>
<span className="apg-ai-spinner" style={{ width: 26, height: 26 }} />
<span style={{ fontSize: 13 }}>Generating with Gemini</span>
</div>
) : null}
</div>
</div>
<div className="apg-editor__panel">
<div className="apg-editor__tabs">
{tabs.map((t) => (
<button
key={t}
type="button"
className={['apg-editor__tab', tab === t ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setTab(t)}
>
{tabLabel(t)}
</button>
))}
</div>
{tab === 'adjust' ? (
<div>
{ADJUST_FIELDS.map((f) => (
<div className="apg-slider-row" key={f.key}>
<div className="apg-slider-row__head">
<span>{f.label}</span>
<span>{Math.round((adj[f.key] ?? 0) * 100)}</span>
</div>
<input
className="apg-slider"
type="range"
min={-1}
max={1}
step={0.01}
value={adj[f.key] ?? 0}
onChange={(e) => setAdj(f.key, Number(e.target.value))}
/>
</div>
))}
</div>
) : null}
{tab === 'filters' ? (
<div className="apg-editor__filters">
{Object.entries(FILTER_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className={[
'apg-editor__filter',
edits.filter === key || (!edits.filter && key === 'original')
? 'apg-editor__filter--active'
: '',
].join(' ')}
onClick={() =>
setEdits((e) => ({ ...e, filter: key === 'original' ? undefined : key }))
}
>
{preset.label}
</button>
))}
</div>
) : null}
{tab === 'crop' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Drag the box to crop. Pick a ratio to lock the shape (a square stays square). Applied
when you press Save.
</p>
<div style={{ fontSize: 12, color: '#9b9ba1' }}>Aspect ratio</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{RATIOS.map((r) => (
<button
key={r.label}
type="button"
className={['apg-editor__tab', cropRatio === r.value ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => pickRatio(r.value)}
>
{r.label}
</button>
))}
<button
type="button"
className={['apg-editor__tab', cropRatio === imgAspect ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => pickRatio(imgAspect)}
>
Original
</button>
</div>
{edits.crop ? (
<button
type="button"
className="apg-editor__tab"
onClick={() => {
setCropRatio(null);
setEdits((e) => ({ ...e, crop: undefined }));
}}
>
Reset Crop
</button>
) : null}
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, rotation: ((e.rotation ?? 0) + 90) % 360 }))}
>
<Icon name="rotate" size={16} /> Rotate 90°
</button>
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, flipH: !e.flipH }))}
>
Flip Horizontal
</button>
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, flipV: !e.flipV }))}
>
Flip Vertical
</button>
<div className="apg-slider-row" style={{ marginTop: 8 }}>
<div className="apg-slider-row__head">
<span>Straighten</span>
<span>{Math.round(edits.straighten ?? 0)}°</span>
</div>
<input
className="apg-slider"
type="range"
min={-45}
max={45}
step={1}
value={edits.straighten ?? 0}
onChange={(e) => setEdits((prev) => ({ ...prev, straighten: Number(e.target.value) }))}
/>
</div>
{(edits.straighten ?? 0) !== 0 ? (
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, straighten: 0 }))}
>
Reset Straighten
</button>
) : null}
</div>
) : null}
{tab === 'markup' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Draw shapes, arrows and measurements. Use <strong>Measure</strong> for a doublearrow
with a centered label (e.g. 12 cm). Pick a tool, then drag on the photo.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{ANN_TOOLS.map((t) => (
<button
key={t.tool}
type="button"
className={['apg-editor__tab', annTool === t.tool ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setAnnTool(t.tool)}
>
<Icon name={t.icon} size={15} /> {t.label}
</button>
))}
</div>
<div>
<div style={{ fontSize: 12, color: '#9b9ba1', marginBottom: 6 }}>Color</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{ANN_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setAnnColor(c)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: c,
border: annColor === c ? '2px solid #fff' : '2px solid rgba(255,255,255,0.25)',
outline: annColor === c ? '2px solid var(--apg-accent)' : 'none',
cursor: 'pointer',
}}
/>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="apg-editor__tab"
style={{ flex: 1 }}
disabled={annotations.length === 0}
onClick={() => setAnnotations(annotations.slice(0, -1))}
>
<Icon name="rotate" size={15} /> Undo
</button>
<button
type="button"
className="apg-editor__tab"
style={{ flex: 1 }}
disabled={annotations.length === 0}
onClick={() => setAnnotations([])}
>
<Icon name="trash" size={15} /> Clear
</button>
</div>
<div style={{ fontSize: 12, color: '#9b9ba1' }}>{annotations.length} annotation(s)</div>
</div>
) : null}
{tab === 'ai' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: '0 0 4px' }}>
Generative edits powered by Gemini. Results replace the photo when you press Save.
</p>
{AI_OPS.map((a) => (
<button
key={a.label}
type="button"
className="apg-editor__tab"
disabled={aiBusy}
onClick={() => void runAI(a.op)}
>
<Icon name={a.icon} size={16} /> {a.label}
</button>
))}
<div style={{ marginTop: 6 }}>
<input
className="apg-modal__input"
style={{ width: '100%', background: 'rgba(255,255,255,0.08)', color: '#fff', borderColor: 'rgba(255,255,255,0.15)' }}
placeholder="Describe an edit, e.g. 'make it golden hour'"
value={aiPrompt}
disabled={aiBusy}
onChange={(e) => setAiPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && aiPrompt.trim()) void runAI({ type: 'prompt', prompt: aiPrompt.trim() });
}}
/>
<button
type="button"
className="apg-btn apg-btn--primary"
style={{ width: '100%', marginTop: 8 }}
disabled={aiBusy || !aiPrompt.trim()}
onClick={() => void runAI({ type: 'prompt', prompt: aiPrompt.trim() })}
>
Apply Prompt
</button>
</div>
{aiResultUrl ? (
<button type="button" className="apg-editor__tab" onClick={clearAI} disabled={aiBusy}>
Discard AI result
</button>
) : null}
{aiError ? (
<p style={{ color: '#ff6b6b', fontSize: 12 }}>{aiError}</p>
) : null}
</div>
) : null}
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
function loadCrossOriginImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not load the image for editing.'));
img.src = src;
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('Failed to read edited image.'));
reader.readAsDataURL(blob);
});
}
@@ -0,0 +1,869 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import { FILTER_PRESETS } from '../../constants';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { editFilterCss } from '../../lib/edits';
import { summarizeEdits } from '../../lib/versions';
import { bakeVideo } from '../../lib/videoBake';
import {
normalizeSegments,
outputDuration,
sampleOverlay,
sourceToOutputTime,
} from '../../lib/videoTimeline';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import type { EditState, MediaId, MediaItem, VideoOverlay, VideoSegment } from '../../types';
import { Annotations, type AnnotationTool } from './Annotations';
import { addToAlbumPicker, confirmAction } from '../modals';
type Tab = 'trim' | 'crop' | 'overlay' | 'filters' | 'adjust' | 'markup' | 'audio' | 'export';
const TABS: Tab[] = ['trim', 'crop', 'overlay', 'filters', 'adjust', 'markup', 'audio', 'export'];
const TAB_LABEL: Record<Tab, string> = {
trim: 'Trim & Split',
crop: 'Crop & Rotate',
overlay: 'Overlays & Text',
filters: 'Filters',
adjust: 'Adjust',
markup: 'Markup',
audio: 'Audio',
export: 'Export',
};
const ANN_TOOLS: Array<{ tool: AnnotationTool; icon: IconName; label: string }> = [
{ tool: 'rect', icon: 'crop', label: 'Rectangle' },
{ tool: 'ellipse', icon: 'info', label: 'Oval' },
{ tool: 'arrow', icon: 'chevron-right', label: 'Arrow' },
{ tool: 'double-arrow', icon: 'aspect', label: 'Measure' },
{ tool: 'text', icon: 'tag', label: 'Text' },
{ tool: 'freehand', icon: 'adjust', label: 'Draw' },
];
const COLORS = ['#ff3b30', '#ffcc00', '#34c759', '#0a84ff', '#ffffff', '#000000'];
const CROP_PRESETS: Array<{ label: string; ratio: number | null }> = [
{ label: 'Original', ratio: null },
{ label: '1:1', ratio: 1 },
{ label: '16:9', ratio: 16 / 9 },
{ label: '9:16', ratio: 9 / 16 },
{ label: '4:3', ratio: 4 / 3 },
];
const QUALITIES: Array<{ label: string; maxDim: number }> = [
{ label: '480p', maxDim: 854 },
{ label: '720p', maxDim: 1280 },
{ label: '1080p', maxDim: 1920 },
];
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result as string);
fr.onerror = rej;
fr.readAsDataURL(blob);
});
}
const fmt = (s: number) =>
`${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`;
/** Centered crop rect for an aspect ratio within a WxH frame (fractions 0..1). */
function centeredCrop(ratio: number | null, aspect: number) {
if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 };
if (ratio > aspect) {
const h = aspect / ratio;
return { x: 0, y: (1 - h) / 2, width: 1, height: h };
}
const w = ratio / aspect;
return { x: (1 - w) / 2, y: 0, width: w, height: 1 };
}
export function VideoEditor() {
const api = useGalleryStoreApi();
const editorId = useGallery((s) => s.editorId);
const media = useGallery((s) => s.media);
const item = media.find((m) => m.id === editorId) ?? null;
const [tab, setTab] = useState<Tab>('trim');
const [edits, setEdits] = useState<EditState>({ adjustments: {} });
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState('#ff3b30');
const [duration, setDuration] = useState(0);
const [playhead, setPlayhead] = useState(0);
const [selOverlay, setSelOverlay] = useState<string | null>(null);
const [previewH, setPreviewH] = useState(360);
const [baking, setBaking] = useState(false);
const [progress, setProgress] = useState(0);
const [exportError, setExportError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const annoWrapRef = useRef<HTMLDivElement>(null);
const overlayFileRef = useRef<HTMLInputElement>(null);
const watermarkFileRef = useRef<HTMLInputElement>(null);
const musicFileRef = useRef<HTMLInputElement>(null);
const cancelRef = useRef<() => void>(() => api.getState().closeEditor());
useFocusTrap(dialogRef, Boolean(item) && item?.kind === 'video', () => cancelRef.current());
// Reset when a different video opens.
useEffect(() => {
setEdits(item?.edits ? { ...item.edits } : { adjustments: {} });
setTab('trim');
setProgress(0);
setPlayhead(0);
setSelOverlay(null);
setExportError(null);
}, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Track the preview box height (drives text-overlay font sizing).
useEffect(() => {
const el = annoWrapRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setPreviewH(el.clientHeight || 360));
ro.observe(el);
return () => ro.disconnect();
}, [item?.id]);
// Keep preview playback within the first→last segment span; track the playhead.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onTime = () => {
setPlayhead(v.currentTime);
const segs = edits.segments && edits.segments.length ? edits.segments : null;
const lo = segs ? segs[0]!.start : (edits.trim?.start ?? 0);
const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration));
if (v.currentTime >= hi) v.currentTime = lo;
};
v.addEventListener('timeupdate', onTime);
return () => v.removeEventListener('timeupdate', onTime);
}, [edits.trim, edits.segments, duration]);
useEffect(() => {
if (videoRef.current) videoRef.current.muted = !!edits.audio?.muted;
}, [edits.audio?.muted]);
if (!item || item.kind !== 'video') return null;
const update = (patch: Partial<EditState>) => setEdits((e) => ({ ...e, ...patch }));
const setAdj = (key: string, val: number) =>
update({ adjustments: { ...edits.adjustments, [key]: val } });
// ---- segments ----
const segments: VideoSegment[] =
edits.segments && edits.segments.length
? edits.segments
: duration
? [{ id: 'seg0', start: 0, end: duration, speed: 1 }]
: [];
const setSegments = (segs: VideoSegment[]) => update({ segments: segs, trim: undefined });
const splitAtPlayhead = () => {
const t = playhead;
const next: VideoSegment[] = [];
for (const s of segments) {
if (t > s.start + 0.1 && t < s.end - 0.1) {
next.push({ ...s, end: t }, { id: nanoid(6), start: t, end: s.end, speed: s.speed });
} else next.push(s);
}
setSegments(next);
};
const outDur = outputDuration(normalizeSegments(edits, duration || 0));
// ---- overlays ----
const overlays: VideoOverlay[] = edits.overlays ?? [];
const setOverlays = (list: VideoOverlay[]) => update({ overlays: list });
const outputTime = sourceToOutputTime(normalizeSegments(edits, duration || 0), playhead);
const addOverlay = (o: VideoOverlay) => {
setOverlays([...overlays, o]);
setSelOverlay(o.id);
};
const patchOverlay = (id: string, patch: Partial<VideoOverlay>) =>
setOverlays(overlays.map((o) => (o.id === id ? { ...o, ...patch } : o)));
const addImageOverlay = (file?: File, watermark = false) => {
if (!file) return;
void blobToDataUrl(file).then((src) =>
addOverlay({
id: nanoid(6),
kind: 'image',
src,
x: 0.05,
y: 0.05,
scale: watermark ? 0.22 : 0.3,
opacity: watermark ? 0.85 : 1,
rotation: 0,
watermark,
}),
);
};
const addTextOverlay = () =>
addOverlay({
id: nanoid(6),
kind: 'text',
text: 'Your text',
color: '#ffffff',
fontSize: 0.09,
bold: true,
x: 0.1,
y: 0.8,
scale: 0.3,
opacity: 1,
rotation: 0,
});
const addKeyframe = (id: string) => {
const o = overlays.find((x) => x.id === id);
if (!o) return;
const s = sampleOverlay(o, outputTime);
const kf = {
t: Math.round(outputTime * 100) / 100,
x: s.x,
y: s.y,
scale: s.scale,
rotation: s.rotation,
opacity: s.opacity,
};
const rest = (o.keyframes ?? []).filter((k) => Math.abs(k.t - kf.t) > 0.05);
patchOverlay(id, { keyframes: [...rest, kf].sort((a, b) => a.t - b.t) });
};
const isDirty = () =>
!!edits.filter ||
(edits.annotations?.length ?? 0) > 0 ||
Object.keys(edits.adjustments ?? {}).length > 0 ||
!!edits.trim ||
!!edits.segments ||
!!edits.overlay ||
(edits.overlays?.length ?? 0) > 0 ||
!!edits.crop ||
!!edits.rotation ||
!!edits.flipH ||
!!edits.flipV ||
!!edits.audio;
const cancel = () => {
if (!isDirty()) return api.getState().closeEditor();
confirmAction({
title: 'Discard changes?',
message: 'Your edits to this video have not been saved.',
confirmLabel: 'Discard',
danger: true,
onConfirm: () => api.getState().closeEditor(),
});
};
cancelRef.current = cancel;
const pickMusic = (file?: File) => {
if (!file) return;
void blobToDataUrl(file).then((src) =>
update({
audio: { ...edits.audio, musicSrc: src, musicVolume: edits.audio?.musicVolume ?? 0.8 },
}),
);
};
const save = async (asCopy: boolean) => {
setBaking(true);
setProgress(0);
setExportError(null);
const changes = summarizeEdits(edits);
try {
const svg = annoWrapRef.current?.querySelector('svg') as SVGSVGElement | null;
const { blob, durationSec, poster } = await bakeVideo(item.src, edits, {
annotationsSvg: edits.annotations?.length ? svg : null,
onProgress: setProgress,
});
const targetId = asCopy ? (nanoid(10) as MediaId) : item.id;
const uploaded = await api.getState().uploadBlob(targetId, blob);
const src = uploaded ?? (await blobToDataUrl(blob));
const patch: Partial<MediaItem> = {
src,
thumbnail: undefined,
poster,
duration: durationSec,
mime: blob.type,
edits: undefined,
editedAt: Date.now(),
analyzedAt: undefined,
};
if (asCopy) {
const newId = api.getState().duplicateWithEdits(item.id, { ...patch, id: targetId }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
api.getState().addVersion(item.id, patch, changes);
api.getState().closeEditor();
}
} catch (err) {
// Surface the failure instead of silently pretending success.
// eslint-disable-next-line no-console
console.error('[VideoEditor] export failed:', err);
setExportError(
err instanceof Error && /cross-origin|tainted|secur/i.test(err.message)
? "This video can't be exported in-browser (cross-origin source without CORS). Re-import it, or host it with CORS enabled."
: 'Export failed. Your edits were saved as a new version so nothing is lost.',
);
// Preserve the edit intent as a version so history is never lost.
if (asCopy) {
const copyId = nanoid(10) as MediaId;
api.getState().duplicateWithEdits(item.id, { id: copyId, edits, editedAt: Date.now() }, changes);
} else {
api.getState().addVersion(item.id, { edits, editedAt: Date.now() }, changes);
}
} finally {
setBaking(false);
}
};
const filterCss = editFilterCss(edits);
const sel = overlays.find((o) => o.id === selOverlay) ?? null;
return (
<AnimatePresence>
<motion.div
ref={dialogRef}
className="apg-editor"
role="dialog"
aria-modal="true"
aria-label={`Edit ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
>
<div className="apg-editor__bar">
<button type="button" className="apg-iconbtn" aria-label="Cancel" onClick={cancel}>
<Icon name="close" />
</button>
<div style={{ fontWeight: 600 }}>Edit Video · {item.name}</div>
<div style={{ flex: 1 }} />
{baking ? (
<span style={{ fontSize: 13, color: 'var(--apg-text-secondary)' }}>
Exporting {Math.round(progress * 100)}%
</span>
) : null}
<button type="button" className="apg-btn" onClick={() => setEdits({ adjustments: {} })} disabled={baking}>
Revert
</button>
<button type="button" className="apg-btn" onClick={() => void save(true)} disabled={baking} title="Export as a new video">
Save as Copy
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save(false)} disabled={baking}>
{baking ? 'Exporting…' : 'Save'}
</button>
</div>
<div className="apg-editor__body">
<div className="apg-editor__canvaswrap">
<div ref={annoWrapRef} style={{ position: 'relative', maxWidth: '100%', maxHeight: '100%' }}>
<video
ref={videoRef}
src={item.src}
controls
playsInline
crossOrigin="anonymous"
style={{ maxWidth: '100%', maxHeight: '70vh', display: 'block', filter: filterCss || undefined }}
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration || 0;
setDuration(d);
setPreviewH(annoWrapRef.current?.clientHeight || 360);
e.currentTarget.currentTime = segments[0]?.start ?? 0;
}}
/>
{/* Live overlay preview (keyframe-interpolated at the current output time). */}
{overlays.map((o) => {
const s = sampleOverlay(o, outputTime);
if (!s.visible) return null;
const selectedRing = o.id === selOverlay ? '0 0 0 2px var(--apg-accent)' : undefined;
if (o.kind === 'image') {
return (
<img
key={o.id}
src={o.src}
alt=""
onClick={() => setSelOverlay(o.id)}
style={{
position: 'absolute',
left: `${s.x * 100}%`,
top: `${s.y * 100}%`,
width: `${s.scale * 100}%`,
opacity: s.opacity,
transform: `rotate(${s.rotation}deg)`,
transformOrigin: 'top left',
boxShadow: selectedRing,
cursor: 'pointer',
}}
/>
);
}
return (
<div
key={o.id}
onClick={() => setSelOverlay(o.id)}
style={{
position: 'absolute',
left: `${s.x * 100}%`,
top: `${s.y * 100}%`,
opacity: s.opacity,
transform: `rotate(${s.rotation}deg)`,
transformOrigin: 'top left',
color: o.color ?? '#fff',
fontSize: `${(o.fontSize ?? 0.08) * previewH}px`,
fontWeight: o.bold ? 700 : 500,
lineHeight: 1,
whiteSpace: 'pre',
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
outline: o.id === selOverlay ? '2px solid var(--apg-accent)' : undefined,
cursor: 'pointer',
}}
>
{o.text}
</div>
);
})}
<Annotations
annotations={edits.annotations ?? []}
editable={tab === 'markup'}
tool={annTool}
color={annColor}
onChange={(annotations) => update({ annotations })}
/>
</div>
</div>
<div className="apg-editor__panel apg-scroll">
<div className="apg-editor__tabs">
{TABS.map((t) => (
<button
key={t}
type="button"
className={['apg-editor__tab', tab === t ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setTab(t)}
>
{TAB_LABEL[t]}
</button>
))}
</div>
{exportError ? (
<div className="apg-editor__error" role="alert">
{exportError}
</div>
) : null}
{/* ---------- TRIM & SPLIT (multi-segment) ---------- */}
{tab === 'trim' ? (
<div className="apg-vedit__panel">
<div className="apg-vedit__hint">
Keep multiple parts at different speeds. Split at the playhead, then trim or delete
each segment. Output length: <strong>{fmt(outDur)}</strong>.
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<button type="button" className="apg-btn apg-btn--primary apg-btn--small" onClick={splitAtPlayhead}>
<Icon name="crop" size={13} /> Split at {fmt(playhead)}
</button>
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() =>
setSegments([
...segments,
{ id: nanoid(6), start: 0, end: Math.min(duration, 3), speed: 1 },
])
}
>
<Icon name="plus" size={13} /> Add
</button>
</div>
{segments.map((s, i) => (
<div key={s.id} className="apg-vedit__seg">
<div className="apg-vedit__seg-head">
<strong>Segment {i + 1}</strong>
<span className="apg-vedit__seg-dur">{fmt(Math.max(0, s.end - s.start))}</span>
{segments.length > 1 ? (
<button
type="button"
className="apg-iconbtn apg-iconbtn--sm"
aria-label="Delete segment"
onClick={() => setSegments(segments.filter((x) => x.id !== s.id))}
>
<Icon name="trash" size={14} />
</button>
) : null}
</div>
<label className="apg-vedit__row">
<span>Start {fmt(s.start)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={s.start}
onChange={(e) => {
const start = Math.min(Number(e.target.value), s.end - 0.2);
setSegments(segments.map((x) => (x.id === s.id ? { ...x, start } : x)));
if (videoRef.current) videoRef.current.currentTime = start;
}}
/>
</label>
<label className="apg-vedit__row">
<span>End {fmt(s.end)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={s.end}
onChange={(e) => {
const end = Math.max(Number(e.target.value), s.start + 0.2);
setSegments(segments.map((x) => (x.id === s.id ? { ...x, end } : x)));
}}
/>
</label>
<label className="apg-vedit__row">
<span>Speed {(s.speed ?? 1).toFixed(2)}×</span>
<input
type="range"
min={0.25}
max={3}
step={0.05}
value={s.speed ?? 1}
onChange={(e) =>
setSegments(
segments.map((x) => (x.id === s.id ? { ...x, speed: Number(e.target.value) } : x)),
)
}
/>
</label>
</div>
))}
</div>
) : null}
{/* ---------- CROP & ROTATE ---------- */}
{tab === 'crop' ? (
<div className="apg-vedit__panel">
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() => update({ rotation: (((edits.rotation ?? 0) + 90) % 360) })}
>
<Icon name="rotate" size={14} /> Rotate 90°
</button>
<button
type="button"
className={['apg-btn apg-btn--small', edits.flipH ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ flipH: !edits.flipH })}
>
Flip H
</button>
<button
type="button"
className={['apg-btn apg-btn--small', edits.flipV ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ flipV: !edits.flipV })}
>
Flip V
</button>
</div>
<div className="apg-vedit__hint" style={{ marginTop: 10 }}>Crop aspect</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{CROP_PRESETS.map((p) => (
<button
key={p.label}
type="button"
className="apg-btn apg-btn--small"
onClick={() => {
const aspect = (item.width || 16) / (item.height || 9);
update({ crop: p.ratio == null ? undefined : centeredCrop(p.ratio, aspect) });
}}
>
{p.label}
</button>
))}
</div>
<div className="apg-vedit__hint" style={{ marginTop: 8 }}>
Crop + rotation + flips are baked into the exported video.
</div>
</div>
) : null}
{/* ---------- OVERLAYS & TEXT (with keyframe animation) ---------- */}
{tab === 'overlay' ? (
<div className="apg-vedit__panel">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" className="apg-btn apg-btn--small apg-btn--primary" onClick={() => overlayFileRef.current?.click()}>
<Icon name="image" size={13} /> Image
</button>
<button type="button" className="apg-btn apg-btn--small" onClick={addTextOverlay}>
<Icon name="tag" size={13} /> Text
</button>
<button type="button" className="apg-btn apg-btn--small" onClick={() => watermarkFileRef.current?.click()}>
<Icon name="image" size={13} /> Watermark
</button>
</div>
<input ref={overlayFileRef} type="file" accept="image/*" hidden onChange={(e) => { addImageOverlay(e.target.files?.[0] ?? undefined, false); e.target.value = ''; }} />
<input ref={watermarkFileRef} type="file" accept="image/*" hidden onChange={(e) => { addImageOverlay(e.target.files?.[0] ?? undefined, true); e.target.value = ''; }} />
{overlays.length === 0 ? (
<div className="apg-vedit__hint">
Add a logo, sticker, watermark, or animated title. Select one to animate it with
keyframes (move / fade / scale over time).
</div>
) : (
<div className="apg-vedit__ovlist">
{overlays.map((o) => (
<button
key={o.id}
type="button"
className={['apg-vedit__ovitem', o.id === selOverlay ? 'is-sel' : ''].join(' ')}
onClick={() => setSelOverlay(o.id)}
>
<Icon name={o.kind === 'text' ? 'tag' : 'image'} size={13} />
<span className="apg-vedit__ovlabel">
{o.kind === 'text' ? o.text || 'Text' : o.watermark ? 'Watermark' : 'Image'}
</span>
{o.keyframes?.length ? <span className="apg-vedit__kfbadge">{o.keyframes.length}</span> : null}
<span
className="apg-iconbtn apg-iconbtn--sm"
role="button"
aria-label="Delete overlay"
onClick={(e) => {
e.stopPropagation();
setOverlays(overlays.filter((x) => x.id !== o.id));
if (selOverlay === o.id) setSelOverlay(null);
}}
>
<Icon name="trash" size={13} />
</span>
</button>
))}
</div>
)}
{sel ? (
<div className="apg-vedit__ovedit">
{sel.kind === 'text' ? (
<>
<label className="apg-vedit__row">
<span>Text</span>
<input
className="apg-modal__input"
value={sel.text ?? ''}
onChange={(e) => patchOverlay(sel.id, { text: e.target.value })}
/>
</label>
<div style={{ display: 'flex', gap: 6, margin: '4px 0' }}>
{COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => patchOverlay(sel.id, { color: c })}
style={{
width: 22, height: 22, borderRadius: '50%', background: c,
border: sel.color === c ? '2px solid var(--apg-accent)' : '1px solid var(--apg-separator)',
cursor: 'pointer',
}}
/>
))}
</div>
<label className="apg-vedit__row">
<span>Size {Math.round((sel.fontSize ?? 0.09) * 100)}</span>
<input type="range" min={0.03} max={0.3} step={0.005} value={sel.fontSize ?? 0.09}
onChange={(e) => patchOverlay(sel.id, { fontSize: Number(e.target.value) })} />
</label>
</>
) : null}
{(['x', 'y', 'scale', 'opacity'] as const).map((k) => (
<label key={k} className="apg-vedit__row">
<span style={{ textTransform: 'capitalize' }}>{k} {(sel[k] ?? (k === 'opacity' ? 1 : 0)).toFixed(2)}</span>
<input
type="range"
min={k === 'scale' ? 0.05 : 0}
max={1}
step={0.01}
value={sel[k] ?? (k === 'opacity' ? 1 : 0)}
onChange={(e) => patchOverlay(sel.id, { [k]: Number(e.target.value) } as Partial<VideoOverlay>)}
/>
</label>
))}
<label className="apg-vedit__row">
<span>Rotation {Math.round(sel.rotation ?? 0)}°</span>
<input type="range" min={-180} max={180} step={1} value={sel.rotation ?? 0}
onChange={(e) => patchOverlay(sel.id, { rotation: Number(e.target.value) })} />
</label>
<div style={{ display: 'flex', gap: 6 }}>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Appear {fmt(sel.in ?? 0)}</span>
<input type="range" min={0} max={outDur} step={0.1} value={sel.in ?? 0}
onChange={(e) => patchOverlay(sel.id, { in: Number(e.target.value) })} />
</label>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Hide {fmt(sel.out ?? outDur)}</span>
<input type="range" min={0} max={outDur} step={0.1} value={sel.out ?? outDur}
onChange={(e) => patchOverlay(sel.id, { out: Number(e.target.value) })} />
</label>
</div>
<div className="apg-vedit__hint" style={{ marginTop: 6 }}>
Keyframes (animate over time) position the playhead, set the transform, then
Add keyframe. Two+ keyframes animate between them.
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="apg-btn apg-btn--small apg-btn--primary" onClick={() => addKeyframe(sel.id)}>
Add keyframe @ {fmt(outputTime)}
</button>
{(sel.keyframes ?? []).map((k, i) => (
<span key={i} className="apg-vedit__kf">
{fmt(k.t)}
<button
type="button"
aria-label="Delete keyframe"
onClick={() =>
patchOverlay(sel.id, { keyframes: (sel.keyframes ?? []).filter((_, j) => j !== i) })
}
>
×
</button>
</span>
))}
</div>
</div>
) : null}
</div>
) : null}
{/* ---------- FILTERS ---------- */}
{tab === 'filters' ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 12 }}>
{Object.entries(FILTER_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className={['apg-btn', edits.filter === key || (key === 'original' && !edits.filter) ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ filter: key === 'original' ? undefined : key })}
>
{preset.label}
</button>
))}
</div>
) : null}
{/* ---------- ADJUST ---------- */}
{tab === 'adjust' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: 12 }}>
{(['brightness', 'contrast', 'saturation', 'warmth'] as const).map((k) => (
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
<span style={{ textTransform: 'capitalize', color: 'var(--apg-text-secondary)' }}>{k}</span>
<input type="range" min={-1} max={1} step={0.01} value={edits.adjustments[k] ?? 0}
onChange={(e) => setAdj(k, Number(e.target.value))} />
</label>
))}
</div>
) : null}
{/* ---------- MARKUP ---------- */}
{tab === 'markup' ? (
<div style={{ padding: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
{ANN_TOOLS.map((t) => (
<button key={t.tool} type="button"
className={['apg-btn', annTool === t.tool ? 'apg-btn--primary' : ''].join(' ')}
style={{ padding: '6px 8px' }} title={t.label} onClick={() => setAnnTool(t.tool)}>
<Icon name={t.icon} size={15} />
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
{COLORS.map((c) => (
<button key={c} type="button" aria-label={`Color ${c}`} onClick={() => setAnnColor(c)}
style={{ width: 24, height: 24, borderRadius: '50%', background: c,
border: annColor === c ? '2px solid var(--apg-accent)' : '1px solid var(--apg-separator)', cursor: 'pointer' }} />
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="apg-btn" onClick={() => update({ annotations: (edits.annotations ?? []).slice(0, -1) })}>Undo</button>
<button type="button" className="apg-btn" onClick={() => update({ annotations: [] })}>Clear</button>
</div>
</div>
) : null}
{/* ---------- AUDIO ---------- */}
{tab === 'audio' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 12, fontSize: 13 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={!!edits.audio?.muted}
onChange={(e) => update({ audio: { ...edits.audio, muted: e.target.checked } })} />
Mute original audio
</label>
{!edits.audio?.muted ? (
<label className="apg-vedit__row">
<span>Original volume {Math.round((edits.audio?.originalVolume ?? 1) * 100)}%</span>
<input type="range" min={0} max={1} step={0.05} value={edits.audio?.originalVolume ?? 1}
onChange={(e) => update({ audio: { ...edits.audio, originalVolume: Number(e.target.value) } })} />
</label>
) : null}
<div style={{ display: 'flex', gap: 8 }}>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Fade in {(edits.audio?.fadeIn ?? 0).toFixed(1)}s</span>
<input type="range" min={0} max={5} step={0.1} value={edits.audio?.fadeIn ?? 0}
onChange={(e) => update({ audio: { ...edits.audio, fadeIn: Number(e.target.value) } })} />
</label>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Fade out {(edits.audio?.fadeOut ?? 0).toFixed(1)}s</span>
<input type="range" min={0} max={5} step={0.1} value={edits.audio?.fadeOut ?? 0}
onChange={(e) => update({ audio: { ...edits.audio, fadeOut: Number(e.target.value) } })} />
</label>
</div>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => musicFileRef.current?.click()}>
<Icon name="play" size={14} /> {edits.audio?.musicSrc ? 'Replace Music' : 'Add Music'}
</button>
<input ref={musicFileRef} type="file" accept="audio/*" hidden
onChange={(e) => { pickMusic(e.target.files?.[0] ?? undefined); e.target.value = ''; }} />
{edits.audio?.musicSrc ? (
<>
<label className="apg-vedit__row">
<span>Music volume {Math.round((edits.audio.musicVolume ?? 0.8) * 100)}%</span>
<input type="range" min={0} max={1} step={0.05} value={edits.audio.musicVolume ?? 0.8}
onChange={(e) => update({ audio: { ...edits.audio, musicVolume: Number(e.target.value) } })} />
</label>
<button type="button" className="apg-btn" onClick={() => update({ audio: { ...edits.audio, musicSrc: undefined } })}>Remove Music</button>
</>
) : null}
</div>
) : null}
{/* ---------- EXPORT (quality + poster) ---------- */}
{tab === 'export' ? (
<div className="apg-vedit__panel">
<div className="apg-vedit__hint">Resolution (longest side)</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{QUALITIES.map((q) => (
<button
key={q.label}
type="button"
className={['apg-btn apg-btn--small', (edits.export?.maxDim ?? 1280) === q.maxDim ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ export: { ...edits.export, maxDim: q.maxDim } })}
>
{q.label}
</button>
))}
</div>
<label className="apg-vedit__row" style={{ marginTop: 10 }}>
<span>Frame rate {edits.export?.fps ?? 30} fps</span>
<input type="range" min={15} max={60} step={1} value={edits.export?.fps ?? 30}
onChange={(e) => update({ export: { ...edits.export, fps: Number(e.target.value) } })} />
</label>
<div className="apg-vedit__hint" style={{ marginTop: 10 }}>Poster / thumbnail</div>
<button type="button" className="apg-btn apg-btn--small" onClick={() => update({ posterTime: outputTime })}>
<Icon name="image" size={13} /> Use current frame ({fmt(outputTime)})
</button>
{edits.posterTime != null ? (
<div className="apg-vedit__hint">Poster set at {fmt(edits.posterTime)}.</div>
) : null}
</div>
) : null}
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,388 @@
'use client';
import { useState } from 'react';
import { downloadMedia } from '../lib/download';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { AlbumId, MediaId, ShareRecord, ViewId } from '../types';
import { closeModal, openModal } from './Modal';
/* ----------------------------- Rename / name prompt ----------------------------- */
function NamePrompt({
title,
initial,
confirmLabel,
placeholder,
onConfirm,
}: {
title: string;
initial: string;
confirmLabel: string;
placeholder?: string;
onConfirm: (name: string) => void;
}) {
const [value, setValue] = useState(initial);
const submit = () => {
const trimmed = value.trim();
if (trimmed) onConfirm(trimmed);
closeModal();
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label={title}>
<div className="apg-modal__title">{title}</div>
<input
className="apg-modal__input"
autoFocus
value={value}
maxLength={120}
placeholder={placeholder ?? 'Album name'}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
}}
/>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={submit}>
{confirmLabel}
</button>
</div>
</div>
);
}
export function promptAlbumName(
title: string,
initial: string,
onConfirm: (name: string) => void,
opts?: { placeholder?: string; confirmLabel?: string },
) {
const confirmLabel =
opts?.confirmLabel ?? (title.toLowerCase().includes('rename') ? 'Rename' : 'Create');
openModal(
<NamePrompt
title={title}
initial={initial}
confirmLabel={confirmLabel}
placeholder={opts?.placeholder}
onConfirm={onConfirm}
/>,
);
}
/* ----------------------------- Album picker ----------------------------- */
function AlbumPickerModal({
ids,
mode = 'copy',
fromAlbumId,
}: {
ids: MediaId[];
mode?: 'copy' | 'move';
fromAlbumId?: string;
}) {
const api = useGalleryStoreApi();
const albums = useGallery((s) =>
s.albums.filter((a) => (a.kind === 'user' || a.kind === 'folder') && a.id !== fromAlbumId),
);
const moving = mode === 'move' && !!fromAlbumId;
const place = (albumId: string) => {
if (moving) api.getState().moveToAlbum(fromAlbumId!, albumId, ids);
else api.getState().addToAlbum(albumId, ids);
closeModal();
};
const createAndPlace = () => {
closeModal();
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
if (moving) api.getState().moveToAlbum(fromAlbumId!, id, ids);
else api.getState().addToAlbum(id, ids);
api.getState().setView(`album:${id}` as ViewId);
});
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label={moving ? 'Move to album' : 'Add to album'}>
<div className="apg-modal__title">
{moving ? 'Move' : 'Add'} {ids.length} item{ids.length === 1 ? '' : 's'} to
</div>
<div className="apg-modal__list">
<button type="button" className="apg-modal__list-item" onClick={createAndPlace}>
New Album
</button>
{albums.map((a) => (
<button key={a.id} type="button" className="apg-modal__list-item" onClick={() => place(a.id)}>
{a.name}
</button>
))}
{albums.length === 0 ? (
<div className="apg-empty-card__text" style={{ padding: '8px 10px' }}>
No albums yet create one above.
</div>
) : null}
</div>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
</div>
</div>
);
}
export function addToAlbumPicker(ids: MediaId[]) {
if (!ids.length) return;
openModal(<AlbumPickerModal ids={ids} />);
}
/** Move items out of `fromAlbumId` into a chosen album. */
export function moveToAlbumPicker(fromAlbumId: string, ids: MediaId[]) {
if (!ids.length) return;
openModal(<AlbumPickerModal ids={ids} mode="move" fromAlbumId={fromAlbumId} />);
}
/* ----------------------------- Confirm dialog ----------------------------- */
export function confirmAction(opts: {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
}) {
openModal(
<div className="apg-modal" role="alertdialog" aria-modal="true" aria-label={opts.title}>
<div className="apg-modal__title">{opts.title}</div>
<div className="apg-empty-card__text">{opts.message}</div>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button
type="button"
className={['apg-btn', opts.danger ? '' : 'apg-btn--primary'].join(' ')}
style={opts.danger ? { background: 'var(--apg-danger)', color: '#fff' } : undefined}
onClick={() => {
closeModal();
opts.onConfirm();
}}
>
{opts.confirmLabel ?? 'Confirm'}
</button>
</div>
</div>,
);
}
/* ----------------------------- Security (lock) settings ----------------------------- */
function SecuritySettingsModal() {
const api = useGalleryStoreApi();
const hasPw = useGallery((s) => s.lock.hash !== null);
const [p1, setP1] = useState('');
const [p2, setP2] = useState('');
const [err, setErr] = useState<string | null>(null);
const save = async () => {
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
if (p1 !== p2) return setErr('Passwords do not match.');
await api.getState().setLockPassword(p1.trim());
closeModal();
};
const remove = () => {
api.getState().removeLockPassword();
closeModal();
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Recently Deleted lock">
<div className="apg-modal__title">
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
</div>
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
{hasPw
? 'Set a new password, or remove the lock. The password is stored only on this device.'
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
</div>
<input
className="apg-modal__input"
type="password"
autoFocus
placeholder={hasPw ? 'New password' : 'Password'}
value={p1}
onChange={(e) => {
setP1(e.target.value);
setErr(null);
}}
/>
<input
className="apg-modal__input"
type="password"
placeholder="Confirm password"
value={p2}
onChange={(e) => {
setP2(e.target.value);
setErr(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void save();
}}
/>
{err ? <div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>{err}</div> : null}
<div className="apg-modal__actions">
{hasPw ? (
<button
type="button"
className="apg-btn"
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
onClick={remove}
>
Remove Lock
</button>
) : null}
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save()}>
{hasPw ? 'Change' : 'Set Password'}
</button>
</div>
</div>
);
}
export function openSecuritySettings() {
openModal(<SecuritySettingsModal />);
}
/* ----------------------------- Share ----------------------------- */
function ShareModal({ selectionIds, albumId }: { selectionIds: MediaId[]; albumId?: AlbumId }) {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums.filter((a) => a.kind === 'user' || a.kind === 'folder'));
const media = useGallery((s) => s.media);
const [picked, setPicked] = useState<Set<AlbumId>>(new Set(albumId ? [albumId] : []));
const [created, setCreated] = useState<ShareRecord | null>(null);
const [copied, setCopied] = useState(false);
const albumMediaIds = (id: AlbumId) =>
media.filter((m) => !m.deletedAt && m.albumIds.includes(id)).map((m) => m.id);
const finish = (share: ShareRecord) => {
setCreated(share);
void navigator.clipboard
?.writeText(share.url)
.then(() => setCopied(true))
.catch(() => setCopied(false));
};
const sharePhotos = () => {
finish(api.getState().createShare(selectionIds.length === 1 ? 'photo' : 'photos', selectionIds));
};
const shareAlbums = () => {
const ids = [...picked];
if (ids.length === 0) return;
const union = [...new Set(ids.flatMap(albumMediaIds))];
finish(api.getState().createShare('album', union, ids.length === 1 ? ids[0] : undefined));
};
const togglePick = (id: AlbumId) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const download = () => {
const ids = created?.mediaIds ?? [];
for (const id of ids) {
const m = media.find((x) => x.id === id);
if (m) downloadMedia(m);
}
};
if (created) {
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Share link">
<div className="apg-modal__title">Link Ready</div>
<div className="apg-empty-card__text">
{created.title} anyone with this link can view {created.mediaIds.length} item
{created.mediaIds.length === 1 ? '' : 's'}.
</div>
<input className="apg-modal__input" readOnly value={created.url} onFocus={(e) => e.currentTarget.select()} />
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={download}>
<Icon name="download" size={14} /> Download
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => {
void navigator.clipboard?.writeText(created.url).then(() => setCopied(true));
}}
>
{copied ? 'Copied ✓' : 'Copy Link'}
</button>
<button type="button" className="apg-btn" onClick={closeModal}>
Done
</button>
</div>
</div>
);
}
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Share">
<div className="apg-modal__title">Share</div>
{selectionIds.length > 0 ? (
<button type="button" className="apg-modal__list-item" onClick={sharePhotos}>
<Icon name="image" size={15} />{' '}
{selectionIds.length === 1 ? 'This photo' : `${selectionIds.length} selected photos`}
</button>
) : null}
<div className="apg-empty-card__text" style={{ margin: '10px 2px 4px' }}>
Or share album{albums.length === 1 ? '' : 's'} (tick one or more):
</div>
<div className="apg-modal__list" style={{ maxHeight: 200 }}>
{albums.map((a) => (
<button
key={a.id}
type="button"
className="apg-modal__list-item"
onClick={() => togglePick(a.id)}
style={picked.has(a.id) ? { background: 'var(--apg-accent)', color: 'var(--apg-accent-contrast)' } : undefined}
>
<Icon name={picked.has(a.id) ? 'check' : 'collections'} size={15} /> {a.name}
</button>
))}
{albums.length === 0 ? (
<div className="apg-empty-card__text" style={{ padding: '8px 10px' }}>No albums yet.</div>
) : null}
</div>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
disabled={picked.size === 0}
onClick={shareAlbums}
>
Share {picked.size > 0 ? `${picked.size} album${picked.size === 1 ? '' : 's'}` : 'Album'}
</button>
</div>
</div>
);
}
/** Open the Share modal. Pass selected media ids and/or the current album id. */
export function openShareModal(selectionIds: MediaId[] = [], albumId?: AlbumId) {
openModal(<ShareModal selectionIds={selectionIds} albumId={albumId} />);
}
@@ -0,0 +1,297 @@
'use client';
import { useState } from 'react';
import { formatDay } from '../../lib/format';
import { groupByTime } from '../../lib/grouping';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { albumMedia, liveMedia, objectLabelCounts } from '../../store/selectors';
import type { MediaItem, ViewId } from '../../types';
import { promptAlbumName } from '../modals';
function SectionHeader({
title,
chevronTo,
action,
}: {
title: string;
chevronTo?: () => void;
action?: { label: string; onClick: () => void };
}) {
return (
<div className="apg-collections__header">
<div
className="apg-collections__title"
onClick={chevronTo}
role={chevronTo ? 'button' : undefined}
tabIndex={chevronTo ? 0 : undefined}
onKeyDown={
chevronTo
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
chevronTo();
}
}
: undefined
}
>
{title}
{chevronTo ? <Icon name="chevron-right" size={18} /> : null}
</div>
{action ? (
<button type="button" className="apg-collections__action" onClick={action.onClick}>
{action.label}
</button>
) : null}
</div>
);
}
function PinnedCard({
label,
cover,
badge,
onClick,
}: {
label: string;
cover?: MediaItem;
badge?: IconName;
onClick: () => void;
}) {
return (
<button type="button" className="apg-pinned-card" onClick={onClick} aria-label={label}>
{cover ? <img src={cover.thumbnail ?? cover.src} alt="" draggable={false} /> : null}
{badge ? (
<span className="apg-pinned-card__badge">
<Icon name={badge} size={16} />
</span>
) : null}
<span className="apg-pinned-card__label">{label}</span>
</button>
);
}
function EmptyCard({
icon,
title,
text,
}: {
icon: IconName;
title: string;
text: string;
}) {
return (
<div className="apg-empty-card">
<span className="apg-empty-card__icon">
<Icon name={icon} size={30} />
</span>
<div className="apg-empty-card__title">{title}</div>
<div className="apg-empty-card__text">{text}</div>
</div>
);
}
export function CollectionsView() {
const api = useGalleryStoreApi();
const media = useGallery((s) => s.media);
const albums = useGallery((s) => s.albums);
const live = liveMedia(media);
const first = (pred: (m: MediaItem) => boolean) => live.find(pred);
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
const go = (v: ViewId) => () => api.getState().setView(v);
const recentDays = groupByTime(live, 'day').slice(0, 8);
const featured = [...live].sort((a, b) => b.takenAt - a.takenAt).slice(0, 12);
const objectEntries = [...objectLabelCounts(media).entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 14);
return (
<div className="apg-scroll">
<div className="apg-collections">
{/* Memories */}
<section className="apg-collections__section">
<SectionHeader title="Memories" />
<EmptyCard
icon="clock"
title="No Memories Available"
text="Memories will appear here when more photos and videos are added to the library."
/>
</section>
{/* Pinned */}
<section className="apg-collections__section">
<SectionHeader title="Pinned" chevronTo={go('collections')} />
<div className="apg-pinned-row">
<PinnedCard label="Favourites" badge="heart-fill" cover={first((m) => m.favorite)} onClick={go('favourites')} />
<PinnedCard label="Recently Saved" cover={first((m) => m.source === 'download' || m.source === 'social')} onClick={go('recently-saved')} />
<PinnedCard label="Map" cover={first((m) => Boolean(m.location))} onClick={go('map')} />
<PinnedCard label="Videos" cover={first((m) => m.kind === 'video')} onClick={go('videos')} />
<PinnedCard label="Screenshots" cover={first((m) => m.source === 'screenshot')} onClick={go('screenshots')} />
<PinnedCard label="Documents" badge="document" cover={first((m) => Boolean(m.ocrText && m.ocrText.trim()))} onClick={go('sys:documents')} />
<PinnedCard label="People & Pets" onClick={go('people')} />
<PinnedCard label="Recently Deleted" badge="trash" onClick={go('recently-deleted')} />
</div>
</section>
{/* Albums */}
<section className="apg-collections__section">
<SectionHeader
title="Albums"
action={{
label: 'Create',
onClick: () =>
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
api.getState().setView(`album:${id}` as ViewId);
}),
}}
/>
{userAlbums.length === 0 ? (
<EmptyCard
icon="collections"
title="No Albums Available"
text="Albums will appear here when they are added to the library or synced."
/>
) : (
<div className="apg-pinned-row">
{userAlbums.map((a) => {
const am = albumMedia(a, media);
return (
<PinnedCard
key={a.id}
label={a.name}
cover={am.find((m) => m.id === a.coverId) ?? am[0]}
onClick={go(a.id as ViewId)}
/>
);
})}
</div>
)}
</section>
{/* Objects (AI) — auto categories from on-device object detection */}
{objectEntries.length > 0 ? (
<section className="apg-collections__section">
<SectionHeader title="Objects" />
<div className="apg-pinned-row">
{objectEntries.map(([label, count]) => (
<button
key={label}
type="button"
className="apg-pinned-card"
onClick={() => api.getState().setView(`sys:obj:${label}` as ViewId)}
aria-label={`${count} photos containing ${label}`}
>
{(() => {
const cover = live.find((m) => m.objectLabels.includes(label));
return cover ? <img src={cover.thumbnail ?? cover.src} alt="" draggable={false} /> : null;
})()}
<span className="apg-pinned-card__label" style={{ textTransform: 'capitalize' }}>
{label}
<span style={{ opacity: 0.8, fontWeight: 500 }}> · {count}</span>
</span>
</button>
))}
</div>
</section>
) : null}
{/* People & Pets */}
<section className="apg-collections__section">
<SectionHeader title="People & Pets" chevronTo={go('people')} />
<EmptyCard
icon="person-circle"
title="Finding People…"
text="Photos creates albums and groups of people and pets found in your library."
/>
</section>
{/* Featured Photos */}
<section className="apg-collections__section">
<SectionHeader title="Featured Photos" />
{featured.length === 0 ? (
<EmptyCard
icon="image"
title="No Featured Photos Available"
text="Featured Photos will appear here when more photos and videos are added."
/>
) : (
<div className="apg-pinned-row">
{featured.map((m) => (
<PinnedCard key={m.id} label="" cover={m} onClick={() => api.getState().openLightbox(m.id)} />
))}
</div>
)}
</section>
{/* Shared Albums */}
<section className="apg-collections__section">
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
<EmptyCard
icon="people"
title="Shared Albums"
text="Share photos and videos with just the people you choose, and let them add photos, videos and comments."
/>
</section>
{/* Recent Days */}
<section className="apg-collections__section">
<SectionHeader title="Recent Days" />
{recentDays.length === 0 ? (
<EmptyCard
icon="clock"
title="No Days Available"
text="Days will appear here when more photos and videos are added to the library."
/>
) : (
<div className="apg-pinned-row">
{recentDays.map((d) => (
<PinnedCard
key={d.key}
label={formatDay(d.items[0]!.takenAt)}
cover={d.items[0]}
onClick={() => api.getState().openLightbox(d.items[0]!.id)}
/>
))}
</div>
)}
</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>
);
}
@@ -0,0 +1,36 @@
'use client';
import { Icon, type IconName } from '../../icons';
export function EmptyState({
icon,
title,
subtitle,
action,
}: {
icon?: IconName;
title: string;
subtitle?: string;
action?: { label: string; onClick: () => void };
}) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
{icon ? (
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name={icon} size={42} />
</span>
) : null}
<div className="apg-empty__title" style={{ fontSize: 26 }}>
{title}
</div>
{subtitle ? <div className="apg-empty__subtitle">{subtitle}</div> : null}
{action ? (
<button type="button" className="apg-btn" onClick={action.onClick}>
{action.label}
</button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,235 @@
'use client';
import { useViewMedia } from '../../hooks/useViewMedia';
import { findDuplicateGroups } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { albumById } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
import { PhotoTile } from '../PhotoTile';
import { EmptyState } from './EmptyState';
const EMPTY_COPY: Record<string, { title: string; subtitle: string; icon: any }> = {
favourites: { title: 'No Favourites', subtitle: 'Tap the heart on a photo to add it here.', icon: 'heart' },
'recently-saved': { title: 'Nothing Saved Yet', subtitle: 'Downloaded and shared media will appear here.', icon: 'download' },
videos: { title: 'No Videos', subtitle: 'Imported videos will appear here.', icon: 'video' },
screenshots: { title: 'No Screenshots', subtitle: 'Screenshots are detected automatically on import.', icon: 'screenshot' },
search: { title: 'Search Your Library', subtitle: 'Search by name, place, tag or detected object.', icon: 'search' },
};
/** Generic grid screen for the simple filtered views. */
export function GridScreen() {
const items = useViewMedia();
const view = useGallery((s) => s.view);
if (items.length === 0) {
const copy = EMPTY_COPY[view] ?? { title: 'No Items', subtitle: '', icon: 'image' };
return <EmptyState icon={copy.icon} title={copy.title} subtitle={copy.subtitle} />;
}
return (
<div className="apg-scroll">
<MediaGrid items={items} />
</div>
);
}
export function AlbumView() {
const view = useGallery((s) => s.view);
const albums = useGallery((s) => s.albums);
const items = useViewMedia();
const album = albumById(albums, view);
return (
<div className="apg-scroll">
<div style={{ padding: '18px 14px 4px' }}>
<div style={{ fontSize: 26, fontWeight: 700 }}>{album?.name ?? 'Album'}</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 13 }}>
{items.length} item{items.length === 1 ? '' : 's'}
</div>
</div>
{items.length === 0 ? (
<EmptyState
icon="collections"
title="No Photos"
subtitle="Select photos in your library and add them to this album."
/>
) : (
<MediaGrid items={items} />
)}
</div>
);
}
export function SharedAlbumsView() {
const api = useGalleryStoreApi();
const shares = useGallery((s) => s.shares);
const media = useGallery((s) => s.media);
if (shares.length === 0) {
return (
<EmptyState
icon="people"
title="Shared Albums"
subtitle="Select photos or an album and choose Share to create a link. Your shares appear here."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16 }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Shared Albums</div>
<div className="apg-pinned-row">
{shares.map((sh) => {
const cover = media.find((m) => m.id === sh.mediaIds[0]);
return (
<div key={sh.id} className="apg-pinned-card" style={{ cursor: 'default' }}>
{cover ? <img src={cover.thumbnail ?? cover.src} alt="" /> : <Icon name="people" size={28} />}
<div className="apg-pinned-card__label">{sh.title}</div>
<div className="apg-pinned-card__badge">{sh.mediaIds.length}</div>
<div
style={{
position: 'absolute',
inset: 'auto 0 0 0',
display: 'flex',
gap: 6,
padding: 6,
background: 'linear-gradient(transparent, rgba(0,0,0,0.55))',
}}
>
<button
type="button"
className="apg-btn"
style={{ flex: 1, padding: '3px 6px', fontSize: 11 }}
onClick={() => void navigator.clipboard?.writeText(sh.url)}
>
Copy Link
</button>
<button
type="button"
className="apg-btn"
style={{ padding: '3px 6px', fontSize: 11, color: 'var(--apg-danger)' }}
onClick={() => api.getState().revokeShare(sh.id)}
>
Revoke
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
export function ActivityView() {
const shares = useGallery((s) => s.shares);
if (shares.length === 0) {
return (
<EmptyState
icon="chat"
title="Activity"
subtitle="Your sharing activity — links you create — will appear here."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16, maxWidth: 640 }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Activity</div>
{shares.map((sh) => (
<div
key={sh.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
marginBottom: 8,
background: 'var(--apg-bg-elevated)',
borderRadius: 12,
}}
>
<span style={{ color: 'var(--apg-accent)' }}>
<Icon name="share" size={18} />
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
You shared {sh.title}
</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
{sh.mediaIds.length} item{sh.mediaIds.length === 1 ? '' : 's'} ·{' '}
{formatRelative(sh.createdAt)}
</div>
</div>
</div>
))}
</div>
);
}
function formatRelative(ts: number): string {
const diff = Date.now() - ts;
const min = Math.round(diff / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min} min ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr} hr ago`;
return `${Math.round(hr / 24)} day(s) ago`;
}
export function DuplicatesView() {
const api = useGalleryStoreApi();
const media = useGallery((s) => s.media);
const groups = findDuplicateGroups(media);
if (groups.length === 0) {
return (
<EmptyState
icon="duplicates"
title="No Duplicates"
subtitle="Exact and near-duplicate items will be grouped here so you can merge them."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16 }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
{groups.length} Duplicate Group{groups.length === 1 ? '' : 's'}
</div>
{groups.map((group, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
marginBottom: 10,
background: 'var(--apg-bg-elevated)',
borderRadius: 12,
}}
>
<div
className="apg-grid"
style={{
gridTemplateColumns: `repeat(${Math.min(group.length, 4)}, 64px)`,
gap: 4,
padding: 0,
flex: 1,
}}
>
{group.slice(0, 4).map((m) => (
<PhotoTile key={m.id} item={m} orderedIds={group.map((g) => g.id)} />
))}
</div>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => api.getState().trash(group.slice(1).map((m) => m.id))}
>
<Icon name="duplicates" size={14} /> Keep 1, Trash {group.length - 1}
</button>
</div>
))}
</div>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useRef, useState } from 'react';
import { useViewMedia } from '../../hooks/useViewMedia';
import { groupByTime } from '../../lib/grouping';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { liveMedia } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
function Welcome({ onImport }: { onImport: () => void }) {
const hints: Array<{ icon: IconName; text: string }> = [
{ icon: 'camera', text: 'Connect a camera or memory card.' },
{ icon: 'duplicates', text: 'Drag pictures directly into Photos.' },
{ icon: 'download', text: 'Click the + button to import.' },
{ icon: 'image', text: 'Turn on iCloud Photos in Settings.' },
];
return (
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
<div className="apg-empty__title">Welcome to Photos</div>
<div className="apg-empty__subtitle">To get started with Photos, do any of the following:</div>
<div className="apg-empty__hints">
{hints.map((h, i) => (
<div className="apg-empty__hint" key={i}>
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name={h.icon} size={40} />
</span>
<span>{h.text}</span>
</div>
))}
</div>
</div>
);
}
export function LibraryView() {
const api = useGalleryStoreApi();
const items = useViewMedia();
const scale = useGallery((s) => s.libraryScale);
const searchQuery = useGallery((s) => s.searchQuery);
const objectFocus = useGallery((s) => s.objectFocus);
const tagFocus = useGallery((s) => s.tagFocus);
const personFocus = useGallery((s) => s.personFocus);
const personName = useGallery((s) => {
if (!s.personFocus) return null;
const p = s.people.find((x) => x.id === s.personFocus);
return p?.name ?? 'Unnamed person';
});
const totalLive = useGallery((s) => liveMedia(s.media).length);
const fileRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const importFiles = async (files: FileList | null) => {
if (!files?.length) return;
await api.getState().importFiles(files);
if (fileRef.current) fileRef.current.value = '';
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
void importFiles(e.dataTransfer.files);
};
let content: React.ReactNode;
if (items.length === 0) {
if (totalLive === 0) {
content = <Welcome onImport={() => fileRef.current?.click()} />;
} else {
content = (
<div className="apg-empty">
<div className="apg-empty__card">
<div className="apg-empty__title" style={{ fontSize: 24 }}>
No Results
</div>
<div className="apg-empty__subtitle">
{objectFocus
? `No photos containing “${objectFocus}”.`
: searchQuery
? 'Try a different search term.'
: 'No items match this filter.'}
</div>
</div>
</div>
);
}
} else if (scale === 'all') {
content = <MediaGrid items={items} />;
} else {
const granularity = scale === 'years' ? 'year' : scale === 'months' ? 'month' : 'day';
const sections = groupByTime(items, granularity);
content = (
<>
{sections.map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))}
</>
);
}
return (
<div
className="apg-scroll"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
style={dragging ? { outline: '3px dashed var(--apg-accent)', outlineOffset: -8 } : undefined}
>
{objectFocus || tagFocus || personFocus ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 12px 0',
fontSize: 14,
fontWeight: 600,
textTransform: 'capitalize',
}}
>
<Icon name={objectFocus ? 'search' : personFocus ? 'person-circle' : 'tag'} size={16} />
{objectFocus
? `Object: ${objectFocus}`
: personFocus
? `Person: ${personName}`
: `Tag: ${tagFocus}`}
<button
type="button"
className="apg-btn"
style={{ marginLeft: 8, padding: '3px 10px', textTransform: 'none' }}
onClick={() =>
objectFocus
? api.getState().setObjectFocus(null)
: personFocus
? api.getState().setPersonFocus(null)
: api.getState().setTagFocus(null)
}
>
Clear
</button>
</div>
) : null}
{content}
<input
ref={fileRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => void importFiles(e.target.files)}
/>
</div>
);
}
@@ -0,0 +1,300 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { MAP_TILES } from '../../constants';
import { groupByTime } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { clusterByLocation, type LocationCluster, liveMedia, locatedMedia } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
import { MosaicGrid } from './MosaicGrid';
export function MapView() {
const api = useGalleryStoreApi();
const mapMode = useGallery((s) => s.mapMode);
const media = useGallery((s) => s.media);
const mapFocus = useGallery((s) => s.mapFocus);
const located = locatedMedia(media);
// The location pin the user tapped → its photos shown date-grouped in a sheet.
const [cluster, setCluster] = useState<LocationCluster | null>(null);
// Sheet height as a fraction of the viewport (drag the handle up → toward full).
const [sheetH, setSheetH] = useState(0.5);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
const onHandleDown = (e: React.PointerEvent) => {
dragRef.current = { startY: e.clientY, startH: sheetH };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onHandleMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const dy = dragRef.current.startY - e.clientY; // dragging up is positive
const next = dragRef.current.startH + dy / window.innerHeight;
setSheetH(Math.max(0.18, Math.min(0.98, next)));
};
const onHandleUp = () => {
if (dragRef.current && sheetH <= 0.2) setCluster(null); // dragged down → dismiss
dragRef.current = null;
};
const containerRef = useRef<HTMLDivElement>(null);
// Leaflet instances kept in refs (typed loosely to avoid an SSR import).
const mapRef = useRef<any>(null);
const tileRef = useRef<any>(null);
const markersRef = useRef<any>(null);
const leafletRef = useRef<any>(null);
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
// are created once, so closing over state directly would go stale).
const locatedRef = useRef(located);
locatedRef.current = located;
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
openClusterRef.current = (c) => {
setCluster(c);
setSheetH(0.5); // reset to half height each time a pin is opened
};
// Create the map once when entering a map tile mode.
useEffect(() => {
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
let cancelled = false;
void import('leaflet').then((mod) => {
const L = (mod as any).default ?? mod;
if (cancelled || !containerRef.current) return;
leafletRef.current = L;
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: true,
worldCopyJump: true,
}).setView([20, 0], 2);
mapRef.current = map;
addTiles();
addMarkers();
// Re-cluster pins each time the zoom changes (precision is zoom-dependent).
map.on('zoomend', addMarkers);
// Center on a focused photo (from the Info mini-map), else fit to all markers.
if (mapFocus) {
map.setView([mapFocus.lat, mapFocus.lng], 12, { animate: false });
} else if (located.length) {
const bounds = L.latLngBounds(located.map((m) => [m.location!.lat, m.location!.lng]));
map.fitBounds(bounds.pad(0.3), { animate: false });
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapMode]);
// Tear the map down when leaving map tile modes.
useEffect(() => {
if (mapMode === 'grid' && mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
tileRef.current = null;
markersRef.current = null;
}
}, [mapMode]);
// Destroy on unmount.
useEffect(() => {
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
tileRef.current = null;
markersRef.current = null;
}
};
}, []);
const addTiles = () => {
const L = leafletRef.current;
const map = mapRef.current;
if (!L || !map) return;
if (tileRef.current) {
map.removeLayer(tileRef.current);
tileRef.current = null;
}
const cfg = mapMode === 'satellite' ? MAP_TILES.satellite : MAP_TILES.map;
tileRef.current = L.tileLayer(cfg.url, {
attribution: cfg.attribution,
maxZoom: cfg.maxZoom,
}).addTo(map);
};
const addMarkers = () => {
const L = leafletRef.current;
const map = mapRef.current;
if (!L || !map) return;
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
markersRef.current.clearLayers();
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
const zoom = map.getZoom?.() ?? 2;
const precision = zoom < 4 ? 0 : zoom < 7 ? 1 : zoom < 11 ? 2 : 3;
const clusters = clusterByLocation(locatedRef.current, precision);
for (const c of clusters) {
const cover = c.items[0]!;
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
// injected; the thumbnail src + count are set via DOM after the marker mounts.
const icon = L.divIcon({
className: 'apg-pin-wrap',
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
// hover tooltip with an image preview + caption.
html:
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
'<span class="apg-pin__count"></span>' +
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
'<span class="apg-pin__tip-cap"></span></span></span>',
iconSize: [56, 64],
iconAnchor: [28, 64],
});
const marker = L.marker([c.lat, c.lng], { icon, keyboard: false });
marker.on('add', () => {
const el: HTMLElement | null = marker.getElement();
if (!el) return;
const thumb = cover.thumbnail ?? cover.src;
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
if (img) img.src = thumb;
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
if (tipImg) tipImg.src = thumb;
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
if (cap) {
// textContent (never innerHTML) — place names are attacker-influencable.
const place = cover.location?.place ?? '';
const n = c.items.length;
cap.textContent = place ? `${place}${n > 1 ? ` · ${n} photos` : ''}` : `${n} photo${n === 1 ? '' : 's'}`;
}
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
if (badge) {
badge.textContent = String(c.items.length);
if (c.items.length < 2) badge.style.display = 'none';
}
});
marker.on('click', () => openClusterRef.current(c));
markersRef.current.addLayer(marker);
}
};
// Swap tile layer when Map/Satellite toggles.
useEffect(() => {
if (mapRef.current) addTiles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapMode]);
// Re-sync markers when the located set changes while the map stays mounted.
useEffect(() => {
if (mapRef.current) addMarkers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [media]);
// Recenter when a photo's location is focused (from the Info mini-map).
useEffect(() => {
if (mapFocus && mapRef.current) {
mapRef.current.setView([mapFocus.lat, mapFocus.lng], 12, { animate: true });
}
}, [mapFocus]);
if (mapMode === 'grid') {
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
// located ones) so it's a rich date-grouped collage, per the design.
const all = liveMedia(media);
if (all.length === 0) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
<div className="apg-empty__title" style={{ fontSize: 22 }}>
No Photos
</div>
<div className="apg-empty__subtitle">Imported photos appear here as a mosaic.</div>
</div>
</div>
);
}
return <MosaicGrid items={all} groupBy="month" />;
}
return (
<div className="apg-map">
<div ref={containerRef} className="apg-map__leaflet" />
<div className="apg-map__controls">
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Zoom in"
onClick={() => mapRef.current?.zoomIn()}
>
<Icon name="plus" />
</button>
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Zoom out"
onClick={() => mapRef.current?.zoomOut()}
>
<Icon name="minus" />
</button>
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Reset north"
onClick={() => mapRef.current?.setView([20, 0], 2)}
>
<Icon name="compass" size={22} />
</button>
</div>
<a className="apg-map__legal" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer noopener">
Legal
</a>
{cluster ? (
<div
className="apg-map__sheet"
role="dialog"
aria-label="Photos at this location"
style={{ height: `${Math.round(sheetH * 100)}%` }}
>
<div
className="apg-map__grip"
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={onHandleUp}
title="Drag to resize"
aria-hidden
>
<span className="apg-map__grip-bar" />
</div>
<div className="apg-map__sheet-head">
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>
{cluster.items[0]?.location?.place ?? 'This location'}
</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
{cluster.items.length} photo{cluster.items.length === 1 ? '' : 's'}
</div>
</div>
<button
type="button"
className="apg-iconbtn"
aria-label="Close"
onClick={() => setCluster(null)}
>
<Icon name="close" />
</button>
</div>
<div className="apg-map__sheet-body apg-scroll">
{groupByTime(cluster.items, 'day').map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
import { editFilterCss } from '../../lib/edits';
import { groupByTime } from '../../lib/grouping';
import { useGalleryStoreApi } from '../../store/context';
import type { MediaItem } from '../../types';
/**
* macOS "Grid" / Memories-style mosaic: date-grouped sections, each led by a
* full-width auto-sliding hero, followed by an irregular grid of tiles with
* varied spans (portrait/landscape/square) rather than a uniform square grid.
*/
// (col-span, row-span) pattern for the tiles under the hero → deliberate variety.
const SPANS: ReadonlyArray<readonly [number, number]> = [
[1, 2],
[1, 2],
[2, 1],
[1, 1],
[2, 2],
[1, 2],
[1, 1],
[2, 1],
];
function Tile({ item, onOpen, span }: { item: MediaItem; onOpen: () => void; span: readonly [number, number] }) {
return (
<button
type="button"
className="apg-mosaic__tile"
style={{ gridColumn: `span ${span[0]}`, gridRow: `span ${span[1]}` }}
onClick={onOpen}
aria-label={item.name}
>
{item.kind === 'video' ? (
<video src={item.src} poster={item.poster} muted preload="metadata" playsInline />
) : (
<img
src={item.thumbnail ?? item.src}
alt=""
loading="lazy"
style={{ filter: editFilterCss(item.edits) }}
/>
)}
</button>
);
}
/** Full-width hero that cross-fades through the section's first few photos. */
function SlideHero({ items, onOpen }: { items: MediaItem[]; onOpen: (id: string) => void }) {
const [i, setI] = useState(0);
const slides = items.slice(0, 6);
useEffect(() => {
if (slides.length < 2) return;
const t = setInterval(() => setI((x) => (x + 1) % slides.length), 3500);
return () => clearInterval(t);
}, [slides.length]);
const current = slides[i % slides.length] ?? items[0]!;
return (
<button type="button" className="apg-mosaic__hero" onClick={() => onOpen(current.id)} aria-label="Open photo">
{slides.map((m, idx) => (
<img
key={m.id}
src={m.thumbnail ?? m.src}
alt=""
style={{ opacity: idx === i % slides.length ? 1 : 0, filter: editFilterCss(m.edits) }}
/>
))}
{slides.length > 1 ? (
<span className="apg-mosaic__dots" aria-hidden>
{slides.map((m, idx) => (
<span key={m.id} className={idx === i % slides.length ? 'is-active' : ''} />
))}
</span>
) : null}
</button>
);
}
export function MosaicGrid({ items, groupBy = 'month' }: { items: MediaItem[]; groupBy?: 'month' | 'day' }) {
const api = useGalleryStoreApi();
const open = (id: string) => api.getState().openLightbox(id);
const sections = groupByTime(items, groupBy);
return (
<div className="apg-scroll apg-mosaic-wrap">
{sections.map((s) => (
<section key={s.key} className="apg-mosaic-section">
<div className="apg-mosaic__title">{s.title}</div>
<SlideHero items={s.items} onOpen={open} />
{s.items.length > 1 ? (
<div className="apg-mosaic">
{s.items.slice(1).map((m, idx) => (
<Tile key={m.id} item={m} span={SPANS[idx % SPANS.length]!} onOpen={() => open(m.id)} />
))}
</div>
) : null}
</section>
))}
</div>
);
}
@@ -0,0 +1,102 @@
'use client';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { promptAlbumName } from '../modals';
export function PeopleView() {
const api = useGalleryStoreApi();
const people = useGallery((s) => s.people);
const media = useGallery((s) => s.media);
if (people.length === 0) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name="person-circle" size={44} />
</span>
<div className="apg-empty__title" style={{ fontSize: 24 }}>
Finding People
</div>
<div className="apg-empty__subtitle">
Photos creates albums and groups of people and pets found in your library when you are
not using the app.
</div>
</div>
</div>
);
}
return (
<div className="apg-scroll">
<div
className="apg-grid"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 18, padding: 18 }}
>
{people.map((p) => {
const cover = media.find((m) => m.id === (p.coverId ?? p.mediaIds[0]));
const rename = () =>
promptAlbumName(
p.name ? `Rename ${p.isPet ? 'pet' : 'person'}` : `Name this ${p.isPet ? 'pet' : 'person'}`,
p.name ?? '',
(name) => api.getState().renamePerson(p.id, name),
{ placeholder: p.isPet ? 'Pet name' : 'Name', confirmLabel: 'Save' },
);
return (
<div
key={p.id}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}
>
<button
type="button"
aria-label={`View photos of ${p.name ?? 'this ' + (p.isPet ? 'pet' : 'person')}`}
style={{
width: 100,
height: 100,
borderRadius: '50%',
overflow: 'hidden',
background: 'var(--apg-bg-elevated)',
display: 'grid',
placeItems: 'center',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
onClick={() => api.getState().setPersonFocus(p.id)}
>
{cover ? (
<img
src={cover.thumbnail ?? cover.src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Icon name={p.isPet ? 'tag' : 'person-circle'} size={40} />
)}
</button>
<button
type="button"
onClick={rename}
title="Click to name"
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: 13,
fontWeight: 500,
color: p.name ? 'var(--apg-text)' : 'var(--apg-accent)',
}}
>
{p.name ?? (p.isPet ? '+ Name pet' : '+ Add Name')}
</button>
<span style={{ fontSize: 11, color: 'var(--apg-text-tertiary)' }}>
{p.mediaIds.length} {p.mediaIds.length === 1 ? 'photo' : 'photos'}
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,113 @@
'use client';
import { groupByTime } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { albumMedia, liveMedia } from '../../store/selectors';
import type { ViewId } from '../../types';
import { promptAlbumName } from '../modals';
import { EmptyState } from './EmptyState';
/**
* "All Albums" overview a meaningful web-gallery view (replaces macOS's
* iOS-only "Projects / App Store" screen). Shows every album as a cover card,
* plus auto "Recent Days" groupings, and a Create action.
*/
export function AlbumsOverview() {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums);
const media = useGallery((s) => s.media);
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
const live = liveMedia(media);
const recentDays = groupByTime(live, 'day').slice(0, 12);
const create = () =>
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
api.getState().setView(`album:${id}` as ViewId);
});
if (userAlbums.length === 0 && live.length === 0) {
return (
<EmptyState
icon="collections"
title="No Albums Yet"
subtitle="Create an album to organize your photos, or import some to get started."
action={{ label: 'Create Album', onClick: create }}
/>
);
}
return (
<div className="apg-scroll">
<div className="apg-collections">
<section className="apg-collections__section">
<div className="apg-collections__header">
<div className="apg-collections__title">My Albums</div>
<button type="button" className="apg-collections__action" onClick={create}>
Create
</button>
</div>
{userAlbums.length === 0 ? (
<div className="apg-empty-card">
<span className="apg-empty-card__icon">
<Icon name="collections" size={28} />
</span>
<div className="apg-empty-card__title">No albums yet</div>
<div className="apg-empty-card__text">Click Create to make your first album.</div>
</div>
) : (
<div className="apg-pinned-row">
{userAlbums.map((a) => {
const am = albumMedia(a, media);
return (
<button
key={a.id}
type="button"
className="apg-pinned-card"
onClick={() => api.getState().setView(a.id as ViewId)}
aria-label={a.name}
>
{(am.find((m) => m.id === a.coverId) ?? am[0]) ? (
<img
src={(am.find((m) => m.id === a.coverId) ?? am[0])!.thumbnail ?? (am.find((m) => m.id === a.coverId) ?? am[0])!.src}
alt=""
draggable={false}
/>
) : null}
<span className="apg-pinned-card__label">
{a.name}
<span style={{ opacity: 0.8, fontWeight: 500 }}> · {am.length}</span>
</span>
</button>
);
})}
</div>
)}
</section>
{recentDays.length > 0 ? (
<section className="apg-collections__section">
<div className="apg-collections__header">
<div className="apg-collections__title">Recent Days</div>
</div>
<div className="apg-pinned-row">
{recentDays.map((d) => (
<button
key={d.key}
type="button"
className="apg-pinned-card"
onClick={() => api.getState().openLightbox(d.items[0]!.id)}
aria-label={d.title}
>
<img src={d.items[0]!.thumbnail ?? d.items[0]!.src} alt="" draggable={false} />
<span className="apg-pinned-card__label">{d.title}</span>
</button>
))}
</div>
</section>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { TRASH_RETENTION_MS } from '../../constants';
import { daysUntilPermanentDelete } from '../../lib/format';
import { Icon } from '../../icons';
import { useViewMedia } from '../../hooks/useViewMedia';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { MediaGrid } from '../MediaGrid';
import { openSecuritySettings } from '../modals';
export function RecentlyDeletedView() {
const api = useGalleryStoreApi();
const items = useViewMedia();
const lockHash = useGallery((s) => s.lock.hash);
const lockUnlocked = useGallery((s) => s.lockUnlocked);
const [pw, setPw] = useState('');
const [error, setError] = useState(false);
// Locked: a password is set and the user hasn't unlocked this session.
if (lockHash && !lockUnlocked) {
const submit = async () => {
const ok = await api.getState().unlockLock(pw);
if (!ok) setError(true);
setPw('');
};
return (
<div className="apg-empty">
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name="lock" size={56} />
</span>
<div className="apg-empty__title" style={{ fontSize: 21 }}>
Enter Your Password to View Recently Deleted
</div>
<input
className="apg-modal__input"
type="password"
autoFocus
value={pw}
placeholder="Password"
style={{ maxWidth: 280 }}
onChange={(e) => {
setPw(e.target.value);
setError(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
}}
/>
{error ? (
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>Incorrect password.</div>
) : null}
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void submit()}>
Unlock
</button>
</div>
);
}
if (items.length === 0) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name="trash" size={40} />
</span>
<div className="apg-empty__title" style={{ fontSize: 22 }}>
No Recently Deleted Items
</div>
<div className="apg-empty__subtitle">
Deleted items are kept here for {Math.round(TRASH_RETENTION_MS / 86400000)} days.
</div>
</div>
</div>
);
}
// Soonest expiry = the smallest remaining days across all trashed items
// (independent of display order, which is sorted by capture date).
const nextExpiry = items.reduce((min, m) => {
if (!m.deletedAt) return min;
return Math.min(min, daysUntilPermanentDelete(m.deletedAt, TRASH_RETENTION_MS));
}, Infinity);
const expiryDays = Number.isFinite(nextExpiry) ? nextExpiry : 0;
return (
<div className="apg-scroll">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '14px 14px 0',
color: 'var(--apg-text-secondary)',
fontSize: 13,
}}
>
<span>
Items are deleted permanently after 30 days. Oldest expires in ~{expiryDays} day
{expiryDays === 1 ? '' : 's'}.
</span>
<button
type="button"
className="apg-btn"
style={{ flexShrink: 0, padding: '4px 10px' }}
onClick={openSecuritySettings}
>
<Icon name={lockHash ? 'lock' : 'unlock'} size={13} />
{lockHash ? 'Locked' : 'Lock…'}
</button>
</div>
<MediaGrid items={items} />
</div>
);
}
@@ -0,0 +1,88 @@
'use client';
import { formatDate, formatTime } from '../../lib/format';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { EmptyState } from './EmptyState';
/**
* Audit browser every photo/video that has an edit history or comments.
* Click an item to open it with the Info panel showing the full version
* timeline (v1 = original, never overwritten) and comment thread.
*/
export function VersionsView() {
const api = useGalleryStoreApi();
const media = useGallery((s) => s.media);
const tracked = media
.filter((m) => !m.deletedAt && ((m.versions?.length ?? 0) > 0 || (m.comments?.length ?? 0) > 0))
.sort((a, b) => (b.editedAt ?? b.importedAt) - (a.editedAt ?? a.importedAt));
const open = (id: string) => {
const s = api.getState();
s.openLightbox(id);
s.setInfoOpen(true);
};
if (tracked.length === 0) {
return (
<EmptyState
icon="clock"
title="No Version History Yet"
subtitle="Edit a photo or video (the original is always kept as version 1) or add a comment, and it will appear here with its full audit log."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16, maxWidth: 760 }}>
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Versions &amp; Audit Log</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 13, marginBottom: 14 }}>
{tracked.length} item{tracked.length === 1 ? '' : 's'} with edit history or comments. Originals are
never overwritten.
</div>
<div className="apg-audit-list">
{tracked.map((m) => {
const versions = m.versions ?? [];
const latest = versions[versions.length - 1];
const comments = m.comments?.length ?? 0;
return (
<button key={m.id} type="button" className="apg-audit-card" onClick={() => open(m.id)}>
<span className="apg-audit-card__thumb-wrap">
<img className="apg-audit-card__thumb" src={m.thumbnail ?? m.src} alt="" />
{m.kind === 'video' ? (
<span className="apg-audit-card__play">
<Icon name="play" size={14} />
</span>
) : null}
</span>
<span className="apg-audit-card__body">
<span className="apg-audit-card__name">{m.name}</span>
<span className="apg-audit-card__tags">
<span className="apg-audit-card__tag">
<Icon name="clock" size={12} />
{versions.length || 1} version{(versions.length || 1) === 1 ? '' : 's'}
</span>
{comments > 0 ? (
<span className="apg-audit-card__tag">
<Icon name="chat" size={12} />
{comments} comment{comments === 1 ? '' : 's'}
</span>
) : null}
</span>
{latest ? (
<span className="apg-audit-card__latest">
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
{formatTime(latest.createdAt)}
</span>
) : null}
</span>
<Icon name="chevron-right" size={16} />
</button>
);
})}
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { EditAdjustments } from './types';
/** Recycle bin retention before items are eligible for permanent deletion. */
export const TRASH_RETENTION_DAYS = 30;
export const TRASH_RETENTION_MS = TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
/** Library grid zoom presets: column count per density step. */
export const GRID_ZOOM_STEPS = [10, 8, 6, 5, 4, 3, 2] as const;
export const DEFAULT_ZOOM_INDEX = 3; // 5 columns
export const ZERO_ADJUSTMENTS: EditAdjustments = {
exposure: 0,
brilliance: 0,
highlights: 0,
shadows: 0,
contrast: 0,
brightness: 0,
blackPoint: 0,
saturation: 0,
vibrance: 0,
warmth: 0,
tint: 0,
sharpness: 0,
definition: 0,
vignette: 0,
};
/** Built-in, one-tap filter presets (Apple-style). Values feed the editor. */
export const FILTER_PRESETS: Record<
string,
{ label: string; adjustments: Partial<EditAdjustments> }
> = {
original: { label: 'Original', adjustments: {} },
vivid: { label: 'Vivid', adjustments: { saturation: 0.35, contrast: 0.15, vibrance: 0.25 } },
vividWarm: { label: 'Vivid Warm', adjustments: { saturation: 0.3, warmth: 0.25, contrast: 0.1 } },
vividCool: { label: 'Vivid Cool', adjustments: { saturation: 0.3, warmth: -0.25, contrast: 0.1 } },
dramatic: { label: 'Dramatic', adjustments: { contrast: 0.4, shadows: -0.25, highlights: -0.15 } },
dramaticWarm: {
label: 'Dramatic Warm',
adjustments: { contrast: 0.4, warmth: 0.2, shadows: -0.2 },
},
dramaticCool: {
label: 'Dramatic Cool',
adjustments: { contrast: 0.4, warmth: -0.2, shadows: -0.2 },
},
mono: { label: 'Mono', adjustments: { saturation: -1, contrast: 0.1 } },
silvertone: { label: 'Silvertone', adjustments: { saturation: -1, brightness: 0.05, contrast: 0.2 } },
noir: { label: 'Noir', adjustments: { saturation: -1, contrast: 0.45, blackPoint: 0.2 } },
};
/** Object labels that auto-generate "AI albums" (like face clustering, for things). */
export const AUTO_ALBUM_OBJECTS = [
'person',
'dog',
'cat',
'car',
'laptop',
'cell phone',
'chair',
'dining table',
'tv',
'bottle',
'cup',
'book',
'potted plant',
'bicycle',
'food',
] as const;
/** Object-detection labels treated as pets/animals — grouped into "People & Pets". */
export const PET_LABELS = ['dog', 'cat', 'bird', 'horse', 'rabbit'] as const;
/** Map tile sources (all free, no API key required). */
export const MAP_TILES = {
map: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri',
maxZoom: 19,
},
} as const;
export const STORAGE_KEY = 'photo-gallery-sdk:v1';
@@ -0,0 +1,79 @@
'use client';
import { type RefObject, useEffect, useRef } from 'react';
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* Accessible modal focus management:
* - moves focus into the container when it opens,
* - traps Tab / Shift+Tab within it,
* - restores focus to the previously-focused element on close.
* Optionally calls `onEscape` when Escape is pressed inside the container.
*/
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
onEscape?: () => void,
) {
const onEscapeRef = useRef(onEscape);
onEscapeRef.current = onEscape;
useEffect(() => {
if (!active || typeof document === 'undefined') return;
const container = ref.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,
);
// Move focus inside on open.
const first = focusables()[0];
if (first) first.focus();
else {
container.setAttribute('tabindex', '-1');
container.focus();
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onEscapeRef.current) {
e.stopPropagation();
onEscapeRef.current();
return;
}
if (e.key !== 'Tab') return;
const list = focusables();
if (list.length === 0) {
e.preventDefault();
return;
}
const firstEl = list[0]!;
const lastEl = list[list.length - 1]!;
const activeEl = document.activeElement;
if (e.shiftKey) {
if (activeEl === firstEl || activeEl === container) {
e.preventDefault();
lastEl.focus();
}
} else if (activeEl === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
container.addEventListener('keydown', onKey);
return () => {
container.removeEventListener('keydown', onKey);
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
};
// ref / onEscapeRef are stable; only re-run when open state flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}
@@ -0,0 +1,69 @@
'use client';
import { useEffect } from 'react';
import { useGalleryStoreApi } from '../store/context';
import { mediaForView } from '../store/selectors';
/** Wire Apple-Photos-style global keyboard shortcuts. */
export function useKeyboardShortcuts() {
const api = useGalleryStoreApi();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
// Never hijack typing in inputs / editable fields.
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
const state = api.getState();
const selection = [...state.selection];
const meta = e.metaKey || e.ctrlKey;
// Cmd/Ctrl+A — select all in the current view.
if (meta && (e.key === 'a' || e.key === 'A')) {
if (state.lightboxId || state.editorId) return;
e.preventDefault();
const ids = mediaForView(state).map((m) => m.id);
state.selectMany(ids);
return;
}
// Delete / Backspace — trash (or permanently delete in the bin).
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
e.preventDefault();
if (state.view === 'recently-deleted') state.deletePermanently(selection);
else state.trash(selection);
return;
}
// F — toggle favourite.
if ((e.key === 'f' || e.key === 'F') && !meta && selection.length) {
e.preventDefault();
state.toggleFavorite(selection);
return;
}
// Escape — clear selection / focus.
if (e.key === 'Escape') {
if (state.objectFocus) state.setObjectFocus(null);
else if (state.selection.size) state.clearSelection();
}
// Enter / Space — open the (single) selection in the lightbox.
if ((e.key === 'Enter' || e.key === ' ') && selection.length === 1 && !state.lightboxId) {
e.preventDefault();
state.openLightbox(selection[0]!);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
}
@@ -0,0 +1,24 @@
'use client';
import { useEffect, useState } from 'react';
/** SSR-safe media-query hook. */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia(query);
const update = () => setMatches(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [query]);
return matches;
}
/** True on phone-sized viewports (the sidebar becomes an overlay drawer). */
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 760px)');
}
@@ -0,0 +1,41 @@
'use client';
import { useMemo } from 'react';
import { useGallery } from '../store/context';
import { mediaForView } from '../store/selectors';
import type { GalleryState } from '../store/store';
import type { MediaItem } from '../types';
/** The media items for the active view (filters + search + object-focus applied). */
export function useViewMedia(): MediaItem[] {
const media = useGallery((s) => s.media);
const albums = useGallery((s) => s.albums);
const view = useGallery((s) => s.view);
const gridFilter = useGallery((s) => s.gridFilter);
const searchQuery = useGallery((s) => s.searchQuery);
const objectFocus = useGallery((s) => s.objectFocus);
const tagFocus = useGallery((s) => s.tagFocus);
const personFocus = useGallery((s) => s.personFocus);
const searchPreset = useGallery((s) => s.searchPreset);
const semanticResults = useGallery((s) => s.semanticResults);
const recentlyViewed = useGallery((s) => s.recentlyViewed);
return useMemo(
() =>
mediaForView({
media,
albums,
view,
gridFilter,
searchQuery,
objectFocus,
tagFocus,
personFocus,
searchPreset,
semanticResults,
recentlyViewed,
} as GalleryState),
[media, albums, view, gridFilter, searchQuery, objectFocus, tagFocus, personFocus, searchPreset, semanticResults, recentlyViewed],
);
}
+334
View File
@@ -0,0 +1,334 @@
'use client';
import type { SVGProps } from 'react';
/**
* Original SF-symbol-inspired icon set drawn from scratch (no Apple assets).
* All icons inherit `currentColor` and a shared 24x24 grid.
*/
export type IconName =
| 'library'
| 'collections'
| 'heart'
| 'heart-fill'
| 'download'
| 'map'
| 'video'
| 'screenshot'
| 'person-circle'
| 'people'
| 'chat'
| 'trash'
| 'lock'
| 'unlock'
| 'duplicates'
| 'folder'
| 'sidebar'
| 'chevron-left'
| 'chevron-right'
| 'chevron-down'
| 'minus'
| 'plus'
| 'aspect'
| 'filter'
| 'ellipsis'
| 'info'
| 'share'
| 'search'
| 'check'
| 'close'
| 'compass'
| 'camera'
| 'crop'
| 'adjust'
| 'filters'
| 'rotate'
| 'wand'
| 'image'
| 'clock'
| 'suitcase'
| 'play'
| 'pause'
| 'volume'
| 'mute'
| 'expand'
| 'pip'
| 'tag'
| 'document'
| 'pin';
export interface IconProps extends SVGProps<SVGSVGElement> {
name: IconName;
size?: number;
}
export function Icon({ name, size = 20, ...rest }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{paths[name]}
</svg>
);
}
const paths: Record<IconName, JSX.Element> = {
library: (
<>
<rect x="3" y="6" width="18" height="13" rx="2.5" />
<path d="M6 3.5h12M4.5 5h15" opacity="0.55" />
<circle cx="9" cy="11" r="1.6" />
<path d="M4 17l4.5-4 3 2.5L15 11l5 5.5" />
</>
),
collections: (
<>
<rect x="4" y="7" width="16" height="12" rx="2.5" />
<path d="M6.5 7V5.5A1.5 1.5 0 0 1 8 4h8a1.5 1.5 0 0 1 1.5 1.5V7" opacity="0.6" />
</>
),
heart: (
<path d="M12 20.6 10.6 19.3C5.7 14.9 2.5 12 2.5 8.4 2.5 5.5 4.8 3.2 7.7 3.2c1.6 0 3.2.8 4.3 2.1 1.1-1.3 2.7-2.1 4.3-2.1 2.9 0 5.2 2.3 5.2 5.2 0 3.6-3.2 6.5-8.1 10.9L12 20.6Z" />
),
'heart-fill': (
<path
d="M12 20.6 10.6 19.3C5.7 14.9 2.5 12 2.5 8.4 2.5 5.5 4.8 3.2 7.7 3.2c1.6 0 3.2.8 4.3 2.1 1.1-1.3 2.7-2.1 4.3-2.1 2.9 0 5.2 2.3 5.2 5.2 0 3.6-3.2 6.5-8.1 10.9L12 20.6Z"
fill="currentColor"
stroke="none"
/>
),
download: (
<>
<path d="M12 4v10m0 0 3.5-3.5M12 14l-3.5-3.5" />
<path d="M5 16v2.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V16" />
</>
),
map: (
<>
<path d="m9 4-5 2v14l5-2 6 2 5-2V4l-5 2-6-2Z" />
<path d="M9 4v14M15 6v14" opacity="0.6" />
</>
),
video: (
<>
<rect x="3" y="7" width="13" height="10" rx="2.5" />
<path d="m16 11 5-3v8l-5-3Z" />
</>
),
screenshot: (
<>
<path d="M4 8V6.5A2.5 2.5 0 0 1 6.5 4H8M16 4h1.5A2.5 2.5 0 0 1 20 6.5V8M20 16v1.5a2.5 2.5 0 0 1-2.5 2.5H16M8 20H6.5A2.5 2.5 0 0 1 4 17.5V16" />
<rect x="8.5" y="8.5" width="7" height="7" rx="1.5" opacity="0.6" />
</>
),
document: (
<>
<path d="M6 3.5h7L18 8v11.5A1.5 1.5 0 0 1 16.5 21h-9A1.5 1.5 0 0 1 6 19.5v-16Z" />
<path d="M13 3.5V8h5" opacity="0.7" />
<path d="M9 12.5h6M9 15.5h6M9 9.5h2.5" />
</>
),
'person-circle': (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="10" r="2.6" />
<path d="M6.8 18a5.4 5.4 0 0 1 10.4 0" />
</>
),
people: (
<>
<circle cx="9" cy="9" r="3" />
<path d="M3.5 18a5.5 5.5 0 0 1 11 0" />
<path d="M15.5 6.2A3 3 0 0 1 18 11M16.5 13.4A5.5 5.5 0 0 1 20.5 18" opacity="0.7" />
</>
),
chat: (
<>
<path d="M4 11a5 5 0 0 1 5-5h3a5 5 0 0 1 0 10H9l-4 3v-3.5A5 5 0 0 1 4 11Z" />
<path d="M9 11h.01M12 11h.01" opacity="0.7" />
</>
),
trash: (
<>
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7" />
<path d="M6.5 7l.8 11A2 2 0 0 0 9.3 20h5.4a2 2 0 0 0 2-1.9L17.5 7" />
</>
),
lock: (
<>
<rect x="5.5" y="10.5" width="13" height="9" rx="2.5" />
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
</>
),
unlock: (
<>
<rect x="5.5" y="10.5" width="13" height="9" rx="2.5" />
<path d="M8 10.5V8a4 4 0 0 1 7.5-1.9" />
</>
),
duplicates: (
<>
<rect x="8" y="8" width="11" height="11" rx="2.5" />
<path d="M5 16V6.5A1.5 1.5 0 0 1 6.5 5H16" opacity="0.7" />
</>
),
folder: (
<path d="M3.5 7.5A1.5 1.5 0 0 1 5 6h4l2 2.5h8A1.5 1.5 0 0 1 20.5 10v7A1.5 1.5 0 0 1 19 18.5H5A1.5 1.5 0 0 1 3.5 17V7.5Z" />
),
sidebar: (
<>
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
<path d="M9.5 5v14" />
</>
),
'chevron-left': <path d="M14.5 6 9 12l5.5 6" />,
'chevron-right': <path d="M9.5 6 15 12l-5.5 6" />,
'chevron-down': <path d="M6 9.5 12 15l6-5.5" />,
minus: <path d="M6 12h12" />,
plus: <path d="M12 6v12M6 12h12" />,
aspect: (
<>
<rect x="4" y="6" width="16" height="12" rx="2.5" />
<path d="M9 6v12" opacity="0.6" />
</>
),
filter: <path d="M5 7h14M8 12h8M10.5 17h3" />,
ellipsis: (
<>
<circle cx="6" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="18" cy="12" r="1.3" fill="currentColor" stroke="none" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 11v5M12 8h.01" />
</>
),
share: (
<>
<path d="M12 4v11M12 4 8.5 7.5M12 4l3.5 3.5" />
<path d="M7 11H6a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a2 2 0 0 0-2-2h-1" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="6" />
<path d="m20 20-3.5-3.5" />
</>
),
check: <path d="m5 12.5 4.5 4.5L19 7" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
compass: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m9 15 2-5 4-2-2 5-4 2Z" fill="currentColor" stroke="none" />
</>
),
camera: (
<>
<path d="M4 9.5A2.5 2.5 0 0 1 6.5 7h1l1-2h7l1 2h1A2.5 2.5 0 0 1 20 9.5V17a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9.5Z" />
<circle cx="12" cy="13" r="3.2" />
</>
),
crop: (
<>
<path d="M7 3v14h14" />
<path d="M3 7h14v14" opacity="0.7" />
</>
),
adjust: (
<>
<path d="M5 8h9M18 8h1M5 16h1M10 16h9" />
<circle cx="16" cy="8" r="2" />
<circle cx="8" cy="16" r="2" />
</>
),
filters: (
<>
<circle cx="9" cy="10" r="5" />
<circle cx="15" cy="14" r="5" opacity="0.7" />
</>
),
rotate: (
<>
<path d="M4.5 12a7.5 7.5 0 1 1 2.2 5.3" />
<path d="M4 13.5 4.5 18l4.5-.5" />
</>
),
wand: (
<>
<path d="m6 18 9-9 1.8 1.8-9 9zM15 6l1.2-1.2M19 8l1.4-1.4M17 4l.4-1.4M20.5 11l1.4-.4" />
</>
),
image: (
<>
<rect x="4" y="5" width="16" height="14" rx="2.5" />
<circle cx="9" cy="10" r="1.6" />
<path d="m5 17 4.5-4 3 2.5L16 12l4 4" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</>
),
suitcase: (
<>
<rect x="4" y="8" width="16" height="11" rx="2.5" />
<path d="M9 8V6.5A1.5 1.5 0 0 1 10.5 5h3A1.5 1.5 0 0 1 15 6.5V8" />
</>
),
play: <path d="M8 6.5 18 12 8 17.5Z" fill="currentColor" stroke="none" />,
pause: (
<>
<rect x="7" y="6" width="3.2" height="12" rx="1" fill="currentColor" stroke="none" />
<rect x="13.8" y="6" width="3.2" height="12" rx="1" fill="currentColor" stroke="none" />
</>
),
volume: (
<>
<path d="M4 9.5v5h3l4.5 3.5v-12L7 9.5H4Z" fill="currentColor" stroke="none" />
<path d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11" />
</>
),
mute: (
<>
<path d="M4 9.5v5h3l4.5 3.5v-12L7 9.5H4Z" fill="currentColor" stroke="none" />
<path d="m15 10 4 4m0-4-4 4" />
</>
),
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" />
),
pip: (
<>
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
<rect x="12" y="12" width="7" height="5.5" rx="1.2" fill="currentColor" stroke="none" />
</>
),
tag: (
<>
<path d="M4 10.5 11 3.5h7.5V11l-7 7L4 10.5Z" />
<circle cx="15" cy="9" r="1.2" fill="currentColor" stroke="none" />
</>
),
pin: (
<>
<path d="M12 21v-7" />
<path d="M8 3h8l-1 6 2 2H7l2-2-1-6Z" />
</>
),
};
+89
View File
@@ -0,0 +1,89 @@
// ============================================================================
// @photo-gallery/sdk — public API
// A reusable, macOS Photos-style photo gallery for React / Next.js.
// ============================================================================
// Main component + props
export { PhotoGallery } from './components/PhotoGallery';
export type { PhotoGalleryProps } from './components/PhotoGallery';
// Headless store access (for advanced composition / external control)
export { useGallery, useGalleryStoreApi, GalleryStoreContext } from './store/context';
export { createGalleryStore, DEFAULT_FEATURES } from './store/store';
export type {
GalleryState,
GalleryStore,
GalleryConfig,
GalleryFeatures,
ThemeTokens,
GridFilter,
} from './store/store';
export * as selectors from './store/selectors';
// Domain types
export type {
MediaItem,
MediaId,
MediaKind,
MediaSource,
Album,
AlbumId,
AlbumKind,
Person,
PersonId,
GeoLocation,
EditState,
EditAdjustments,
Annotation,
AnnotationShape,
DetectedObject,
DetectedFace,
SmartRule,
SmartRuleSet,
ThemePreference,
ResolvedTheme,
LibraryScale,
MapMode,
ViewId,
} from './types';
// Storage adapters
export { createLocalStorageAdapter } from './adapters/localStorage';
export type { StorageAdapter, PersistedState } from './adapters/types';
// AI provider interface (pluggable; free in-browser or cloud)
export type { AIProvider, GenerativeEditOp } from './ai/types';
export { cosineSimilarity } from './ai/types';
// Helpers
export { createMediaItem, mediaFromFile, isAcceptedMediaFile } from './lib/media';
export type { MediaInput } from './lib/media';
export { classifyMediaSource, sourceLabel } from './lib/classify';
export { matchesRuleSet, resolveSmartAlbum, defaultSystemAlbums, objectSmartAlbums } from './lib/smartAlbums';
export { editFilterCss, editTransformCss } from './lib/edits';
export { summarizeEdits } from './lib/versions';
export {
normalizeSegments,
outputDuration,
sourceToOutputTime,
videoOutputSize,
resolveOverlays,
sampleOverlay,
} from './lib/videoTimeline';
export { bakeVideo } from './lib/videoBake';
// Pure, dependency-free AI post-processing utils (reusable + unit-testable).
export { clusterFaces } from './lib/cluster';
export type { FaceCluster } from './lib/cluster';
export { sanitizeOcrText, OCR_TEXT_MAX_CHARS } from './lib/text';
// Icons (re-usable in host apps)
export { Icon } from './icons';
export type { IconName } from './icons';
// Constants
export {
FILTER_PRESETS,
GRID_ZOOM_STEPS,
TRASH_RETENTION_DAYS,
AUTO_ALBUM_OBJECTS,
} from './constants';
+109
View File
@@ -0,0 +1,109 @@
import type { Annotation, EditState } from '../types';
import { editFilterCss } from './edits';
export interface BakeResult {
blob: Blob;
width: number;
height: number;
annotations: Annotation[];
}
/** True if the edit stack contains geometry that must be flattened into pixels. */
export function hasGeometry(edits?: EditState): boolean {
if (!edits) return false;
const c = edits.crop;
const cropped = !!c && (c.x > 0.001 || c.y > 0.001 || c.width < 0.999 || c.height < 0.999);
return cropped || !!edits.rotation || !!edits.straighten || !!edits.flipH || !!edits.flipV;
}
/**
* Flatten the visual edit stack (crop flips filter rotation/straighten) of
* a (CORS-clean) image into a new JPEG Blob. Annotations are re-mapped into the
* cropped space (when un-rotated) so they stay aligned.
*/
export async function bakeEdits(
img: HTMLImageElement,
edits: EditState,
): Promise<BakeResult> {
const natW = img.naturalWidth || img.width;
const natH = img.naturalHeight || img.height;
const c = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
const sx = Math.round(c.x * natW);
const sy = Math.round(c.y * natH);
const sw = Math.max(1, Math.round(c.width * natW));
const sh = Math.max(1, Math.round(c.height * natH));
// Canvas A: cropped region with flips + colour filter baked in.
const a = document.createElement('canvas');
a.width = sw;
a.height = sh;
const actx = a.getContext('2d');
if (!actx) throw new Error('Canvas unavailable');
const filter = editFilterCss(edits);
if (filter) actx.filter = filter;
const fH = edits.flipH ? -1 : 1;
const fV = edits.flipV ? -1 : 1;
actx.save();
actx.translate(fH < 0 ? sw : 0, fV < 0 ? sh : 0);
actx.scale(fH, fV);
actx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
actx.restore();
// Optional vignette.
const vig = edits.adjustments?.vignette ?? 0;
if (vig > 0) {
actx.filter = 'none';
const g = actx.createRadialGradient(sw / 2, sh / 2, Math.min(sw, sh) * 0.35, sw / 2, sh / 2, Math.max(sw, sh) * 0.75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, `rgba(0,0,0,${Math.min(0.85, vig * 0.85).toFixed(2)})`);
actx.fillStyle = g;
actx.fillRect(0, 0, sw, sh);
}
// Rotation (90/180/270 + fine straighten).
const deg = ((edits.rotation ?? 0) + (edits.straighten ?? 0)) % 360;
let out = a;
if (deg !== 0) {
const rad = (deg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad));
const sin = Math.abs(Math.sin(rad));
const ow = Math.round(sw * cos + sh * sin);
const oh = Math.round(sw * sin + sh * cos);
const o = document.createElement('canvas');
o.width = ow;
o.height = oh;
const octx = o.getContext('2d');
if (!octx) throw new Error('Canvas unavailable');
octx.translate(ow / 2, oh / 2);
octx.rotate(rad);
octx.drawImage(a, -sw / 2, -sh / 2);
out = o;
}
const blob = await new Promise<Blob>((resolve, reject) =>
out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.92),
);
// Re-map annotations into the cropped coordinate space (skip when rotated).
let annotations: Annotation[] = [];
if (edits.annotations?.length && deg === 0) {
const remap = (x: number, y: number) => ({
x: (x - c.x) / c.width,
y: (y - c.y) / c.height,
});
annotations = edits.annotations.map((an) => {
const p1 = remap(an.x1, an.y1);
const p2 = remap(an.x2, an.y2);
return {
...an,
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
points: an.points?.map((pt) => remap(pt.x, pt.y)),
};
});
}
return { blob, width: out.width, height: out.height, annotations };
}
+78
View File
@@ -0,0 +1,78 @@
import type { MediaSource } from '../types';
/**
* Heuristic media-source classification no AI model required.
*
* Uses filename patterns, dimensions, mime type and the presence of camera EXIF
* data to guess whether a file is a screenshot, a download, a social-app save,
* a scanned document, or a real camera capture. This mirrors how Photos
* auto-populates its "Screenshots" / "Recents" smart albums.
*/
export interface ClassifyInput {
name: string;
width: number;
height: number;
mime: string;
hasCameraExif?: boolean;
/** Common device screen sizes help confirm screenshots. */
knownScreenSizes?: Array<[number, number]>;
}
const SCREENSHOT_NAME = /(screen[\s_-]?shot|screenshot|screen recording|capture)/i;
const SOCIAL_NAME = /(whatsapp|img-\d{8}-wa\d+|telegram|instagram|insta|fb_img|facebook|messenger|signal|snapchat)/i;
const DOWNLOAD_NAME = /(download|untitled|unsplash|pexels|pixabay|getty|shutterstock|wallpaper)/i;
const SCAN_NAME = /(scan|scanned|cam[\s_-]?scanner|doc[\s_-]?scan|adobe[\s_-]?scan)/i;
const AI_NAME = /(midjourney|dall[\s_-]?e|stable[\s_-]?diffusion|sdxl|ai[\s_-]?generated|upscayl|gigapixel|remaster)/i;
const CAMERA_NAME = /(img_\d+|dsc[_-]?\d+|dscf\d+|p\d{7}|gopr\d+|dji_\d+|_mg_\d+|pxl_\d+)/i;
const DEFAULT_SCREEN_SIZES: Array<[number, number]> = [
[1170, 2532], [1284, 2778], [1080, 2400], [1080, 1920], [1440, 3200],
[2532, 1170], [2778, 1284], [2400, 1080], [1920, 1080],
[2560, 1440], [3840, 2160], [1280, 800], [2880, 1800], [1366, 768],
];
export function classifyMediaSource(input: ClassifyInput): MediaSource {
const { name, width, height, mime, hasCameraExif } = input;
const lower = name.toLowerCase();
if (mime.startsWith('video/')) {
if (SCREENSHOT_NAME.test(lower)) return 'screenshot'; // screen recording
if (SOCIAL_NAME.test(lower)) return 'social';
return hasCameraExif ? 'camera' : 'imported';
}
if (AI_NAME.test(lower)) return 'ai';
if (SCAN_NAME.test(lower)) return 'scanned';
if (SCREENSHOT_NAME.test(lower)) return 'screenshot';
if (SOCIAL_NAME.test(lower)) return 'social';
// An image at an exact device resolution with no camera EXIF -> almost certainly a screenshot.
const sizes = input.knownScreenSizes ?? DEFAULT_SCREEN_SIZES;
const matchesScreen = sizes.some(([w, h]) => w === width && h === height);
if (!hasCameraExif && matchesScreen) {
return 'screenshot';
}
if (CAMERA_NAME.test(lower) || hasCameraExif) return 'camera';
if (DOWNLOAD_NAME.test(lower)) return 'download';
// PNGs without camera data are usually web downloads or graphics, not captures.
if (mime === 'image/png' && !hasCameraExif) return 'download';
return 'imported';
}
/** Human-friendly label for a media source. */
export function sourceLabel(source: MediaSource): string {
switch (source) {
case 'camera': return 'Camera';
case 'screenshot': return 'Screenshot';
case 'download': return 'Downloaded';
case 'social': return 'Social';
case 'scanned': return 'Scanned';
case 'ai': return 'AI Generated';
case 'imported': return 'Imported';
default: return 'Unknown';
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { MediaId, MediaItem } from '../types';
/**
* Face clustering groups detected faces across the library into people.
*
* Faces carry a 128-D embedding (from the provider's face-recognition model).
* Two faces of the same person sit close in that space; different people sit far
* apart. We do a simple, deterministic online clustering: process faces largest
* first (big, frontal faces make better seeds), and assign each to the nearest
* existing centroid within `threshold`, else start a new cluster.
*
* Kept dependency-free and pure so it runs anywhere and is trivially testable.
*/
/** Max Euclidean distance between L2-ish descriptors for "same person".
* face-api's recommended cut-off is 0.6; we use a slightly tighter 0.55 to
* favour precision (fewer wrong merges) over recall. */
const DEFAULT_THRESHOLD = 0.55;
export interface FaceCluster {
/** Unique media ids in this cluster, ordered by first appearance. */
mediaIds: MediaId[];
/** Item whose face is the most prominent (largest box) — used as the cover. */
coverId: MediaId;
/** Mean descriptor of the cluster. */
centroid: number[];
/** Number of individual faces (not items) merged into this cluster. */
faceCount: number;
}
function euclidean(a: number[], b: number[]): number {
let sum = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const d = a[i]! - b[i]!;
sum += d * d;
}
return Math.sqrt(sum);
}
export function clusterFaces(media: MediaItem[], threshold = DEFAULT_THRESHOLD): FaceCluster[] {
// Flatten every embedded face; bigger faces first for stabler seed centroids.
const faces: { itemId: MediaId; emb: number[]; area: number }[] = [];
for (const m of media) {
if (m.deletedAt) continue;
for (const f of m.faces ?? []) {
if (f.embedding && f.embedding.length > 0) {
faces.push({ itemId: m.id, emb: f.embedding, area: f.box.width * f.box.height });
}
}
}
faces.sort((a, b) => b.area - a.area);
interface Acc {
sum: number[];
n: number;
centroid: number[];
members: { itemId: MediaId; area: number }[];
}
const clusters: Acc[] = [];
for (const f of faces) {
let best = -1;
let bestD = Infinity;
for (let i = 0; i < clusters.length; i++) {
const d = euclidean(clusters[i]!.centroid, f.emb);
if (d < bestD) {
bestD = d;
best = i;
}
}
if (best >= 0 && bestD <= threshold) {
const c = clusters[best]!;
for (let k = 0; k < f.emb.length; k++) c.sum[k] = (c.sum[k] ?? 0) + f.emb[k]!;
c.n += 1;
c.centroid = c.sum.map((s) => s / c.n);
c.members.push({ itemId: f.itemId, area: f.area });
} else {
clusters.push({
sum: [...f.emb],
n: 1,
centroid: [...f.emb],
members: [{ itemId: f.itemId, area: f.area }],
});
}
}
return clusters.map((c) => {
const seen = new Set<MediaId>();
const mediaIds: MediaId[] = [];
let coverId = c.members[0]!.itemId;
let coverArea = -1;
for (const m of c.members) {
if (!seen.has(m.itemId)) {
seen.add(m.itemId);
mediaIds.push(m.itemId);
}
if (m.area > coverArea) {
coverArea = m.area;
coverId = m.itemId;
}
}
return { mediaIds, coverId, centroid: c.centroid, faceCount: c.n };
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Lightweight password hashing for the client-side album lock.
*
* NOTE: this is a convenience/privacy UX feature, not server-grade auth. The
* hash is computed with the Web Crypto API (SHA-256 over a fixed app salt + the
* password) and stored locally. It keeps casual onlookers out of the Recently
* Deleted view on a shared device; it is NOT a substitute for real authz.
*/
const APP_SALT = 'apg::recently-deleted::v1';
function toHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Returns `sha256:<hex>` for the given password, or '' if Web Crypto is absent. */
export async function hashPassword(password: string): Promise<string> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return '';
const data = new TextEncoder().encode(`${APP_SALT}:${password}`);
const digest = await subtle.digest('SHA-256', data);
return `sha256:${toHex(digest)}`;
}
/** Constant-time-ish comparison of a candidate password against a stored hash. */
export async function verifyPassword(password: string, storedHash: string | null): Promise<boolean> {
if (!storedHash) return false;
const candidate = await hashPassword(password);
if (candidate.length !== storedHash.length) return false;
let diff = 0;
for (let i = 0; i < candidate.length; i++) diff |= candidate.charCodeAt(i) ^ storedHash.charCodeAt(i);
return diff === 0;
}
+52
View File
@@ -0,0 +1,52 @@
import type { MediaItem } from '../types';
/** Trigger a browser download of a single media item by URL. */
export function downloadMedia(item: MediaItem): void {
if (typeof document === 'undefined') return;
const a = document.createElement('a');
a.href = item.src;
a.download = sanitizeFilename(item.name);
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
/** Export a metadata-only JSON sidecar for the current selection. */
export function exportMetadata(items: MediaItem[]): void {
if (typeof document === 'undefined') return;
const payload = items.map((i) => ({
name: i.name,
takenAt: new Date(i.takenAt).toISOString(),
source: i.source,
tags: i.tags,
objects: i.objectLabels,
location: i.location,
favorite: i.favorite,
width: i.width,
height: i.height,
}));
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'photos-metadata.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Sanitize a filename to prevent path traversal / illegal characters when it is
* used as a download target. Security-relevant: never trust an imported name.
*/
export function sanitizeFilename(name: string): string {
const cleaned = name
// Strip path separators and characters illegal on common filesystems.
.replace(/[/\\?%*:|"<>]/g, '_')
// Collapse parent-directory sequences (path traversal guard).
.replace(/\.\.+/g, '_')
.trim();
return cleaned.slice(0, 200) || 'photo';
}
+56
View File
@@ -0,0 +1,56 @@
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../constants';
import type { EditAdjustments, EditState } from '../types';
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
function resolveAdjustments(edits: EditState): EditAdjustments {
const preset = edits.filter ? FILTER_PRESETS[edits.filter]?.adjustments ?? {} : {};
return { ...ZERO_ADJUSTMENTS, ...preset, ...edits.adjustments };
}
/** Build a CSS `filter` string approximating the adjustment stack. */
export function editFilterCss(edits?: EditState): string | undefined {
if (!edits) return undefined;
const a = resolveAdjustments(edits);
const brightness = clamp(
1 + a.exposure * 0.4 + a.brightness * 0.4 + a.shadows * 0.12 - a.highlights * 0.06,
0.2,
2.2,
);
const contrast = clamp(
1 + a.contrast * 0.5 + a.definition * 0.2 + a.sharpness * 0.15 + a.blackPoint * 0.25,
0.2,
2.6,
);
const saturate = clamp(1 + a.saturation * 0.85 + a.vibrance * 0.35, 0, 3);
const sepia = clamp(Math.max(0, a.warmth) * 0.5, 0, 1);
const hue = (a.warmth < 0 ? a.warmth * 14 : 0) + a.tint * 14;
const parts = [
`brightness(${brightness.toFixed(3)})`,
`contrast(${contrast.toFixed(3)})`,
];
// Heavily desaturated presets read as true monochrome.
if (saturate <= 0.2) parts.push('grayscale(1)');
else parts.push(`saturate(${saturate.toFixed(3)})`);
if (sepia > 0.01) parts.push(`sepia(${sepia.toFixed(3)})`);
if (Math.abs(hue) > 0.5) parts.push(`hue-rotate(${hue.toFixed(1)}deg)`);
return parts.join(' ');
}
/** Build a CSS `transform` for rotation + straighten + flips. */
export function editTransformCss(edits?: EditState): string | undefined {
if (!edits) return undefined;
const straighten = edits.straighten ?? 0;
const rot = (edits.rotation ?? 0) + straighten;
// Scale up slightly when straightening so the rotated image still covers the frame.
const cover = straighten ? 1 + Math.min(0.5, Math.abs(straighten) / 90) : 1;
const sx = (edits.flipH ? -1 : 1) * cover;
const sy = (edits.flipV ? -1 : 1) * cover;
if (!rot && sx === 1 && sy === 1) return undefined;
return `rotate(${rot}deg) scale(${sx}, ${sy})`;
}
export { ZERO_ADJUSTMENTS };
+76
View File
@@ -0,0 +1,76 @@
/** Formatting helpers for dates, sizes and durations. */
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
export function formatBytes(bytes?: number): string {
if (!bytes || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatDuration(seconds?: number): string {
if (!seconds || seconds < 0) return '';
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
const pad = (n: number) => n.toString().padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function formatDate(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
export function formatDay(ms: number): string {
const d = new Date(ms);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (sameDay(d, today)) return 'Today';
if (sameDay(d, yesterday)) return 'Yesterday';
const weekday = d.toLocaleDateString(undefined, { weekday: 'long' });
return `${weekday}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
export function formatMonth(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
export function formatYear(ms: number): string {
return new Date(ms).getFullYear().toString();
}
export function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
export function relativeTime(ms: number): string {
const diff = Date.now() - ms;
const day = 24 * 60 * 60 * 1000;
if (diff < 0) return 'in the future';
if (diff < day) return 'today';
const days = Math.floor(diff / day);
if (days === 1) return 'yesterday';
if (days < 30) return `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} month${months > 1 ? 's' : ''} ago`;
const years = Math.floor(days / 365);
return `${years} year${years > 1 ? 's' : ''} ago`;
}
/** Days remaining before a trashed item is permanently removed. */
export function daysUntilPermanentDelete(deletedAt: number, retentionMs: number): number {
const remaining = deletedAt + retentionMs - Date.now();
return Math.max(0, Math.ceil(remaining / (24 * 60 * 60 * 1000)));
}
+63
View File
@@ -0,0 +1,63 @@
import type { MediaItem } from '../types';
import { formatDay, formatMonth, formatYear } from './format';
export interface MediaSection<T = MediaItem> {
key: string;
title: string;
subtitle?: string;
items: T[];
}
type Granularity = 'day' | 'month' | 'year';
function bucketKey(ms: number, granularity: Granularity): string {
const d = new Date(ms);
switch (granularity) {
case 'day': return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
case 'month': return `${d.getFullYear()}-${d.getMonth()}`;
case 'year': return `${d.getFullYear()}`;
}
}
function bucketTitle(ms: number, granularity: Granularity): string {
switch (granularity) {
case 'day': return formatDay(ms);
case 'month': return formatMonth(ms);
case 'year': return formatYear(ms);
}
}
/**
* Group media into reverse-chronological sections by day/month/year.
* Used by the Library's Years / Months / All Photos views.
*/
export function groupByTime(
items: MediaItem[],
granularity: Granularity,
): MediaSection[] {
const sorted = [...items].sort((a, b) => b.takenAt - a.takenAt);
const map = new Map<string, MediaSection>();
for (const item of sorted) {
const key = bucketKey(item.takenAt, granularity);
let section = map.get(key);
if (!section) {
section = { key, title: bucketTitle(item.takenAt, granularity), items: [] };
map.set(key, section);
}
section.items.push(item);
}
return [...map.values()];
}
/** Find probable duplicates by (name, bytes, dimensions) signature. */
export function findDuplicateGroups(items: MediaItem[]): MediaItem[][] {
const map = new Map<string, MediaItem[]>();
for (const item of items) {
if (item.deletedAt) continue;
const sig = `${item.name.toLowerCase()}|${item.bytes ?? 0}|${item.width}x${item.height}`;
const arr = map.get(sig) ?? [];
arr.push(item);
map.set(sig, arr);
}
return [...map.values()].filter((g) => g.length > 1);
}
+253
View File
@@ -0,0 +1,253 @@
import { nanoid } from 'nanoid';
import type { MediaItem, MediaKind } from '../types';
import { classifyMediaSource } from './classify';
export type MediaInput = Partial<MediaItem> & { src: string };
/** Normalize loose input into a fully-formed MediaItem with sensible defaults. */
export function createMediaItem(input: MediaInput): MediaItem {
const now = Date.now();
const kind: MediaKind = input.kind ?? (input.mime?.startsWith('video/') ? 'video' : 'image');
const name = input.name ?? 'Untitled';
const width = input.width ?? 1600;
const height = input.height ?? 1067;
const mime = input.mime ?? (kind === 'video' ? 'video/mp4' : 'image/jpeg');
const source =
input.source ??
classifyMediaSource({
name,
width,
height,
mime,
hasCameraExif: Boolean(input.exif && Object.keys(input.exif).length > 0),
});
return {
id: input.id ?? nanoid(10),
kind,
src: input.src,
thumbnail: input.thumbnail,
poster: input.poster,
name,
width,
height,
mime,
bytes: input.bytes,
takenAt: input.takenAt ?? now,
importedAt: input.importedAt ?? now,
editedAt: input.editedAt,
deletedAt: input.deletedAt,
duration: input.duration,
favorite: input.favorite ?? false,
hidden: input.hidden ?? false,
source,
albumIds: input.albumIds ?? [],
tags: input.tags ?? [],
objectLabels: input.objectLabels ?? [],
personIds: input.personIds ?? [],
location: input.location,
exif: input.exif,
caption: input.caption,
objects: input.objects,
faces: input.faces,
analyzedAt: input.analyzedAt,
colorPalette: input.colorPalette,
blurScore: input.blurScore,
qualityScore: input.qualityScore,
embedding: input.embedding,
isLivePhoto: input.isLivePhoto,
isRaw: input.isRaw,
isPanorama: input.isPanorama,
edits: input.edits,
// Preserve edit history + comments through the single normalization path so
// versions/comments survive a reload (localStorage adapter re-normalizes on load).
versions: input.versions,
comments: input.comments,
};
}
/**
* Validate a media URL. Allows http(s), data:image/video, blob, and same-origin
* relative paths; rejects dangerous schemes (javascript:, vbscript:, file:, ).
* Security: untrusted `src`/`thumbnail`/`poster` must never carry an active scheme.
*/
export function safeMediaUrl(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim() : '';
if (!s) return undefined;
if (/^(https?:|blob:)/i.test(s)) return s;
if (/^data:(image|video)\//i.test(s)) return s;
// No scheme at all → treat as a relative same-origin path.
if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return s;
return undefined; // unknown / disallowed scheme
}
const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
const asStringArray = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
const asFiniteNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
/**
* Coerce an untrusted object (from props or persisted storage) into a safe
* MediaItem. Returns null if it has no usable source. This is the single
* normalization path so persisted and prop-supplied data are validated alike.
*/
export function normalizeMediaItem(raw: unknown): MediaItem | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const src = safeMediaUrl(r.src);
if (!src) return null;
let location: MediaItem['location'];
if (r.location && typeof r.location === 'object') {
const loc = r.location as Record<string, unknown>;
const lat = asFiniteNumber(loc.lat);
const lng = asFiniteNumber(loc.lng);
if (lat !== undefined && lng !== undefined) {
location = { lat, lng, place: asString(loc.place), city: asString(loc.city), country: asString(loc.country) };
}
}
return createMediaItem({
...(r as MediaInput),
src,
thumbnail: safeMediaUrl(r.thumbnail),
poster: safeMediaUrl(r.poster),
name: asString(r.name),
caption: asString(r.caption),
tags: asStringArray(r.tags),
objectLabels: asStringArray(r.objectLabels),
albumIds: asStringArray(r.albumIds),
personIds: asStringArray(r.personIds),
location,
});
}
/** Accepted upload types — guards against importing arbitrary/executable files. */
const ACCEPTED = /^(image\/(jpeg|png|gif|webp|heic|heif|avif|bmp|tiff)|video\/(mp4|quicktime|webm|x-matroska|3gpp))$/;
export function isAcceptedMediaFile(file: File): boolean {
return ACCEPTED.test(file.type) || /\.(jpe?g|png|gif|webp|heic|heif|avif|bmp|tiff?|mp4|mov|webm|mkv|3gp)$/i.test(file.name);
}
/** Read a Blob/File into a base64 data: URL. Durable across reloads (unlike blob: URLs). */
export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
/** Build a MediaItem from a browser File (reads real dimensions / duration). */
export async function mediaFromFile(file: File): Promise<MediaItem | null> {
if (!isAcceptedMediaFile(file)) return null;
const isVideo = file.type.startsWith('video/') || /\.(mp4|mov|webm|mkv|3gp)$/i.test(file.name);
const url = URL.createObjectURL(file);
try {
if (isVideo) {
const meta = await readVideoMeta(url);
return createMediaItem({
src: url,
name: file.name,
mime: file.type || 'video/mp4',
bytes: file.size,
kind: 'video',
takenAt: file.lastModified || Date.now(),
width: meta.width,
height: meta.height,
duration: meta.duration,
});
}
const dims = await readImageMeta(url);
const meta = await readExif(file);
return createMediaItem({
src: url,
name: file.name,
mime: file.type || 'image/jpeg',
bytes: file.size,
kind: 'image',
takenAt: meta.takenAt ?? file.lastModified ?? Date.now(),
width: dims.width,
height: dims.height,
isPanorama: dims.width / Math.max(1, dims.height) > 2.2,
location: meta.location,
exif: meta.exif,
tags: meta.tags,
});
} catch {
URL.revokeObjectURL(url);
return null;
}
}
interface ExifMeta {
takenAt?: number;
location?: MediaItem['location'];
exif?: Record<string, string | number>;
/** Existing keywords/tags from IPTC/XMP. */
tags?: string[];
}
/** Parse EXIF + IPTC/XMP (date, GPS, camera, keywords) from an image File. Lazy-loads `exifr`. */
async function readExif(file: File): Promise<ExifMeta> {
try {
const exifr = (await import('exifr')).default;
// Parse GPS + IPTC + XMP so existing keywords/tags and location are imported.
const data = await exifr.parse(file, { gps: true, iptc: true, xmp: true });
if (!data) return {};
const out: ExifMeta = {};
const dt = data.DateTimeOriginal ?? data.CreateDate;
if (dt) {
const t = new Date(dt).getTime();
if (Number.isFinite(t)) out.takenAt = t;
}
if (typeof data.latitude === 'number' && typeof data.longitude === 'number') {
out.location = { lat: data.latitude, lng: data.longitude };
}
const exif: Record<string, string | number> = {};
for (const k of ['Make', 'Model', 'LensModel', 'ISO', 'FNumber', 'FocalLength'] as const) {
const v = data[k];
if (typeof v === 'string' || typeof v === 'number') exif[k] = v;
}
if (Object.keys(exif).length) out.exif = exif;
// Import existing keywords/tags from IPTC (Keywords) and XMP (dc:subject).
const raw: unknown[] = [];
const push = (v: unknown) => {
if (Array.isArray(v)) raw.push(...v);
else if (typeof v === 'string') raw.push(...v.split(/[;,]/));
};
push(data.Keywords);
push(data.subject);
const tags = [...new Set(raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean))];
if (tags.length) out.tags = tags;
return out;
} catch {
return {};
}
}
function readImageMeta(url: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = reject;
img.src = url;
});
}
function readVideoMeta(url: string): Promise<{ width: number; height: number; duration: number }> {
return new Promise((resolve, reject) => {
const v = document.createElement('video');
v.preload = 'metadata';
v.onloadedmetadata = () =>
resolve({ width: v.videoWidth, height: v.videoHeight, duration: v.duration });
v.onerror = reject;
v.src = url;
});
}
+169
View File
@@ -0,0 +1,169 @@
import type { Album, MediaItem, SmartRule, SmartRuleSet } from '../types';
/** Evaluate a single smart rule against a media item. */
function evalRule(item: MediaItem, rule: SmartRule): boolean {
const { field, op, value } = rule;
const get = (): unknown => {
switch (field) {
case 'source': return item.source;
case 'kind': return item.kind;
case 'favorite': return item.favorite;
case 'mime': return item.mime;
case 'name': return item.name;
case 'takenAt': return item.takenAt;
case 'hasLocation': return Boolean(item.location);
case 'hasText': return Boolean(item.ocrText && item.ocrText.trim().length > 0);
case 'isRaw': return Boolean(item.isRaw);
case 'isLivePhoto': return Boolean(item.isLivePhoto);
case 'isPanorama': return Boolean(item.isPanorama);
case 'tag': return item.tags;
case 'object': return item.objectLabels;
case 'person': return item.personIds;
default: return undefined;
}
};
const actual = get();
switch (op) {
case 'eq': return actual === value;
case 'neq': return actual !== value;
case 'isTrue': return actual === true;
case 'isFalse': return actual === false || actual === undefined;
case 'gt': return typeof actual === 'number' && typeof value === 'number' && actual > value;
case 'lt': return typeof actual === 'number' && typeof value === 'number' && actual < value;
case 'contains':
if (Array.isArray(actual)) {
return actual.some(
(v) => typeof v === 'string' && typeof value === 'string' && v.toLowerCase().includes(value.toLowerCase()),
);
}
if (typeof actual === 'string' && typeof value === 'string') {
return actual.toLowerCase().includes(value.toLowerCase());
}
return false;
default: return false;
}
}
/** Does an item satisfy a rule set (AND/OR over its rules)? */
export function matchesRuleSet(item: MediaItem, ruleSet: SmartRuleSet): boolean {
if (item.deletedAt) return false; // trashed items never appear in smart albums
if (ruleSet.rules.length === 0) return true;
return ruleSet.match === 'all'
? ruleSet.rules.every((r) => evalRule(item, r))
: ruleSet.rules.some((r) => evalRule(item, r));
}
/** Resolve the live member ids of a smart album from the full library. */
export function resolveSmartAlbum(album: Album, items: MediaItem[]): string[] {
if (!album.ruleSet) return [];
return items.filter((i) => matchesRuleSet(i, album.ruleSet!)).map((i) => i.id);
}
/**
* The system smart albums Photos ships with. These are recomputed from the
* library whenever it changes, so they always reflect current content.
*/
export function defaultSystemAlbums(now: number): Album[] {
const base = (
id: string,
name: string,
icon: string,
ruleSet: SmartRuleSet,
pinned = true,
): Album => ({
id,
name,
kind: 'smart',
mediaIds: [],
ruleSet,
createdAt: now,
pinned,
system: true,
icon,
});
return [
base('sys:favourites', 'Favourites', 'heart', {
match: 'all',
rules: [{ field: 'favorite', op: 'isTrue' }],
}),
base('sys:videos', 'Videos', 'video', {
match: 'all',
rules: [{ field: 'kind', op: 'eq', value: 'video' }],
}),
base('sys:screenshots', 'Screenshots', 'screenshot', {
match: 'all',
rules: [{ field: 'source', op: 'eq', value: 'screenshot' }],
}),
base('sys:documents', 'Documents', 'document', {
match: 'all',
rules: [{ field: 'hasText', op: 'isTrue' }],
}),
base('sys:selfies', 'Selfies', 'person', {
match: 'any',
rules: [{ field: 'tag', op: 'contains', value: 'selfie' }],
}, false),
base('sys:recently-saved', 'Recently Saved', 'download', {
match: 'any',
rules: [
{ field: 'source', op: 'eq', value: 'download' },
{ field: 'source', op: 'eq', value: 'social' },
],
}),
base('sys:raw', 'RAW', 'raw', {
match: 'all',
rules: [{ field: 'isRaw', op: 'isTrue' }],
}, false),
base('sys:live', 'Live Photos', 'live', {
match: 'all',
rules: [{ field: 'isLivePhoto', op: 'isTrue' }],
}, false),
base('sys:panoramas', 'Panoramas', 'pano', {
match: 'all',
rules: [{ field: 'isPanorama', op: 'isTrue' }],
}, false),
];
}
/** Title-case a detected object label for display ("dining table" → "Dining Table"). */
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Auto-generate one live smart album per DETECTED OBJECT label e.g. every photo
* containing a "chair" is grouped into a "Chair" album, "table" into "Table", etc.
* Membership is resolved live (via resolveSmartAlbum) as detection tags more photos;
* these are system albums (never persisted regenerated on load).
*
* @param minCount only surface a label once it appears on at least this many photos.
*/
export function objectSmartAlbums(media: MediaItem[], now: number, minCount = 1): Album[] {
const counts = new Map<string, number>();
for (const m of media) {
if (m.deletedAt || m.hidden) continue;
// De-dupe labels within one item so a single photo counts once per label.
for (const label of new Set(m.objectLabels)) {
const key = label.trim().toLowerCase();
if (!key) continue;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
return [...counts.entries()]
.filter(([, n]) => n >= minCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([label]) => ({
id: `sys:obj:${label}`,
name: titleCase(label),
kind: 'smart' as const,
mediaIds: [],
ruleSet: { match: 'all', rules: [{ field: 'object', op: 'contains', value: label }] } as SmartRuleSet,
createdAt: now,
pinned: false,
system: true,
icon: 'tag',
}));
}
+28
View File
@@ -0,0 +1,28 @@
/** Hard cap on stored OCR text prevents a pathological image from bloating
* the store / backend row (DoS) and keeps search tokenization cheap. */
export const OCR_TEXT_MAX_CHARS = 10_000;
// C0 control characters + DEL. Built from a string of \u escapes so the source
// stays pure ASCII. Replaced with spaces (then collapsed) so a newline between
// two words doesn't fuse them into a single search token.
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001F\\u007F]', 'g');
/**
* Clean OCR output before it is stored or searched.
*
* OCR text is attacker-influencable (it comes from pixels inside an imported
* image), so we treat it as untrusted input at the write boundary: strip
* control characters, collapse whitespace into single spaces, and length-cap.
* No HTML stripping is required because the value is only ever rendered through
* React/textContent (never innerHTML) but if a future search-highlight feature
* is added it MUST build nodes with createElement + textContent, never innerHTML.
*/
export function sanitizeOcrText(input: string): string {
const cleaned = input.replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
if (cleaned.length <= OCR_TEXT_MAX_CHARS) return cleaned;
// Truncate on a word boundary so a word straddling the cap isn't split into an
// unsearchable fragment (search does substring matching on whole tokens).
const cut = cleaned.slice(0, OCR_TEXT_MAX_CHARS);
const lastSpace = cut.lastIndexOf(' ');
return lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
}

Some files were not shown because too many files have changed in this diff Show More