first commit
This commit is contained in:
@@ -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".
|
||||
@@ -0,0 +1,46 @@
|
||||
# Requirements backlog (complete)
|
||||
|
||||
Status: ✅ done · 🚧 in progress · ⬜ planned · 🔑 needs key/creds. Cross-ref `/docs/ROADMAP.md`.
|
||||
|
||||
## Done (Phases 1–3)
|
||||
- ✅ 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.
|
||||
@@ -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 1–3 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).
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
shamefully-hoist=false
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -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 Photos–style photo gallery SDK** (`@photo-gallery/sdk`) for React / Next.js, plus a
|
||||
Next.js demo (`apps/web`). Goal: a near carbon-copy of macOS Photos (light/dark/semi-dark, fully
|
||||
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.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Photo Gallery Web SDK
|
||||
|
||||
A **reusable, macOS Photos‑style 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 near‑pixel 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 / semi‑dark**, with **macOS "glass" vibrancy** (backdrop‑blur 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 date‑grouped. **Grid** tab = Memories‑style mosaic (auto‑sliding hero + irregular tiles) |
|
||||
| **Import / capture** | Drag‑and‑drop & file‑picker (validated types); **custom camera** (photo/video, front/back, grid, live annotation + measurement, geolocation, auto object‑detect) |
|
||||
| **Sharing** | Share a photo / selection / one‑or‑more albums → copyable link + download; **Shared Albums** + **Activity** feed |
|
||||
| **Recycle bin** | 30‑day trash; **password lock** (Web Crypto, device‑local) with lock/unlock states + Settings |
|
||||
|
||||
### AI — all **free & in‑browser** (no API key), lazy‑loaded, pluggable
|
||||
| Capability | Engine |
|
||||
| --- | --- |
|
||||
| **Object detection** → tags, search, Objects browser | TensorFlow.js COCO‑SSD |
|
||||
| **Face clustering** → People (rename); **pet detection** → Pets | face‑api.js (128‑D descriptors) |
|
||||
| **OCR** → searchable text + Documents album | tesseract.js (confidence‑filtered) |
|
||||
| **Semantic search** — "show me snowy mountains" | CLIP via transformers.js |
|
||||
| **Remove Background** | @imgly (in‑browser WASM) |
|
||||
| **Generative edits** (restore / colorize / sky / prompt) 🔑 | Gemini (server‑proxied; needs a key) |
|
||||
|
||||
### Editors
|
||||
| Area | Details |
|
||||
| --- | --- |
|
||||
| **Photo editor** | Adjust (13 sliders), 10 filters, **crop** (free + fixed‑ratio) & straighten, **markup** (rect/oval/line/arrow/double‑arrow measurement/text/freehand), AI tab — **Save** or **Save as Copy**, Cancel‑confirm |
|
||||
| **Video editor** | Adjust · Filters · **Trim** · **Markup** · **Overlay** (composite an image) · **Audio** (mute + add music) — exports a new clip in‑browser (canvas + MediaRecorder + WebAudio, no ffmpeg); **Save / Save as Copy** |
|
||||
|
||||
### Platform
|
||||
| Area | Details |
|
||||
| --- | --- |
|
||||
| **Backend** | Pluggable `StorageAdapter` — zero‑config localStorage/IndexedDB default, or **Supabase** (Postgres + Storage) adapter included |
|
||||
| **Search** | Name / place / tag / object / **OCR text** / **semantic** — blended and relevance‑ranked |
|
||||
| **Theming** | `accentColor` + `borderRadius` props; Light / Dark / Semi‑Dark / System |
|
||||
| **A11y / responsive** | Keyboard nav, focus traps, reduced‑motion, ARIA; works down to ~280 px |
|
||||
| **Security** | Per‑request **nonce CSP**, security headers, filename sanitisation, URL‑scheme allow‑list, server‑only API keys |
|
||||
|
||||
---
|
||||
|
||||
## 🗂 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 SF‑style SVG icon set
|
||||
│ │ └── styles/ # sdk.css (self‑contained, 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 + auto‑classified |
|
||||
| `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` | – | Per‑theme 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 traffic‑light 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 built‑in
|
||||
light / dark / semi‑dark 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 client‑side
|
||||
(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 Content‑Security‑Policy** + `X‑Frame‑Options: DENY`, `nosniff`, `Referrer‑Policy`,
|
||||
`Permissions‑Policy` (see [`apps/web/next.config.mjs`](apps/web/next.config.mjs)).
|
||||
- **No `eval`, no `dangerouslySetInnerHTML`** anywhere in the SDK.
|
||||
- **Import allow‑list** — only image/video MIME types and extensions are accepted.
|
||||
- **Filename sanitisation** on export (path‑traversal guard).
|
||||
- **Defensive persistence** — persisted state is shape‑validated before use.
|
||||
|
||||
---
|
||||
|
||||
## 🛣 Roadmap (Phase 2+)
|
||||
|
||||
**Live now** (all free & in‑browser unless marked 🔑):
|
||||
|
||||
- **Object detection** (TF.js COCO‑SSD) → tags, search, Objects browser.
|
||||
- **Face clustering → People** (rename) + **pet detection → Pets** (face‑api.js).
|
||||
- **OCR → searchable text + Documents album** (tesseract.js, confidence‑filtered).
|
||||
- **Semantic search** — natural‑language "looks like" queries (CLIP via transformers.js).
|
||||
- **Remove Background** (@imgly, in‑browser WASM).
|
||||
- **Supabase backend** adapter (Postgres + Storage), alongside the zero‑config localStorage default.
|
||||
- **Full video editor** (trim / filters / markup / image overlay / mute + music) exporting a new clip in‑browser.
|
||||
- **Sharing** (links + Shared Albums + Activity), **password‑locked Recently Deleted**, **custom camera**.
|
||||
- **Generative AI editor** 🔑 (Gemini) — the editor's **AI tab** (Restore, Colorize, Replace Sky, free‑form
|
||||
prompts). The key stays **server‑side 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 billing‑enabled / paid plan**. A free‑tier 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 image‑generation quota.
|
||||
|
||||
Still planned / not yet implemented:
|
||||
|
||||
- **Auth & multi‑user sharing** (real recipients, permissions). Sharing today creates device‑local links.
|
||||
- **Captions** (transformers.js BLIP) and a first‑class Hugging Face Inference provider option.
|
||||
- **Map** heatmaps / trips / reverse‑geocode place names; true grid **virtualization** for millions of items.
|
||||
- **Server‑side** 30‑day purge (pg_cron), Storage blob deletion on permanent‑delete.
|
||||
- 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 self‑contained stylesheet** — Tailwind is not required by
|
||||
consumers.
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -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[] } }>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GalleryClient } from '../../components/GalleryClient';
|
||||
|
||||
export default function GalleryPage() {
|
||||
return <GalleryClient />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
@@ -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 [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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` |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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 macOS‑style light / dark / semi‑dark 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** + **semi‑dark** (content); dark values apply in **dark** mode
|
||||
and to the **semi‑dark 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
@@ -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
|
||||
@@ -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
@@ -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');
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 & 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 & drop here, or click to browse</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--apg-text-secondary)' }}>
|
||||
Images & 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 double‑arrow
|
||||
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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
},
|
||||
satellite: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: 'Tiles © 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],
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user