Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b685b194 | |||
| 06a689bbd3 | |||
| e9914caa30 | |||
| 2613c767a6 | |||
| 2c38ce811f | |||
| 0c2b7ef132 |
@@ -14,3 +14,83 @@ BFF_ORIGIN=http://localhost:4000
|
|||||||
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
|
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
|
||||||
# locally: export NODE_AUTH_TOKEN=<token> before npm install
|
# locally: export NODE_AUTH_TOKEN=<token> before npm install
|
||||||
# Vercel: set NODE_AUTH_TOKEN as a project env var
|
# Vercel: set NODE_AUTH_TOKEN as a project env var
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SMART GALLERY — AI routes (/api/gallery/ai/*). Full docs: docs/SMART_GALLERY.md
|
||||||
|
# ===========================================================================
|
||||||
|
#
|
||||||
|
# AUTH: these routes are session-gated. When NEXT_PUBLIC_SUPABASE_URL above is
|
||||||
|
# SET, every AI request is verified against ${BFF_ORIGIN}/api/session/context and
|
||||||
|
# a non-200 is rejected. When it is UNSET the app is in local demo mode and the
|
||||||
|
# AI routes are UNAUTHENTICATED — never expose such a deployment publicly while
|
||||||
|
# RUNPOD_API_KEY is set, or anyone can spend your GPU budget.
|
||||||
|
#
|
||||||
|
# NOTHING below is required to run the gallery: object detection, faces, OCR and
|
||||||
|
# semantic search all run FREE in-browser with no key. Only generative editing,
|
||||||
|
# transcription, denoise and tilt need RunPod.
|
||||||
|
|
||||||
|
# --- RunPod credentials -----------------------------------------------------
|
||||||
|
# Server-side ONLY. Never prefix with NEXT_PUBLIC_ — that would ship the key to
|
||||||
|
# the browser. Read exclusively by src/lib/server/runpod/client.ts.
|
||||||
|
RUNPOD_API_KEY=rpa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# --- Per-model endpoint URLs (deploy each so the URL ends in /runsync) -------
|
||||||
|
# Several models can share one endpoint id — that is expected, not a mistake.
|
||||||
|
RUNPOD_SD_IMG2IMG_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #10 prompt / colorize (img2img)
|
||||||
|
RUNPOD_SD_INPAINT_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #9 replace-sky / magic-eraser / generative-fill / outpaint (masked)
|
||||||
|
RUNPOD_YOLO_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #1 object detection → /api/gallery/ai/classify
|
||||||
|
RUNPOD_UPSCALE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #7 restore / upscale (Real-ESRGAN)
|
||||||
|
RUNPOD_STT_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #3 speech-to-text → /api/gallery/ai/transcribe
|
||||||
|
RUNPOD_BG_REMOVE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #6 background removal (U²-Net)
|
||||||
|
RUNPOD_AUDIO_DENOISE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #12 audio denoise → /api/gallery/ai/denoise
|
||||||
|
# Optional, only if you deploy them:
|
||||||
|
# RUNPOD_TILT_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #2 camera tilt → /api/gallery/ai/tilt (needs the switch below)
|
||||||
|
# RUNPOD_COLORIZE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #8 DDColor (currently unused — colorize goes through img2img)
|
||||||
|
|
||||||
|
# --- Which backend serves /api/gallery/ai/edit ------------------------------
|
||||||
|
# auto = first configured of: runpod → local → huggingface → gemini
|
||||||
|
# runpod = the RUNPOD_*_URL endpoints above (recommended)
|
||||||
|
# local = your own Stable Diffusion (A1111/Forge/SD.Next); needs LOCAL_SD_URL
|
||||||
|
# huggingface = HF Inference API; needs HF_API_TOKEN (+ optional HF_IMAGE_MODEL)
|
||||||
|
# gemini = Google Gemini; needs GEMINI_API_KEY (image output requires a BILLED key)
|
||||||
|
# none = disable generative editing entirely (503 with a clear message)
|
||||||
|
AI_EDIT_PROVIDER=runpod
|
||||||
|
|
||||||
|
# Alternative backends (only read when AI_EDIT_PROVIDER selects them):
|
||||||
|
# LOCAL_SD_URL=http://127.0.0.1:7860
|
||||||
|
# LOCAL_SD_DENOISE=0.55
|
||||||
|
# LOCAL_SD_STEPS=25
|
||||||
|
# LOCAL_SD_SAMPLER=Euler a
|
||||||
|
# HF_API_TOKEN=hf_xxxxxxxx
|
||||||
|
# HF_IMAGE_MODEL=timbrooks/instruct-pix2pix
|
||||||
|
# GEMINI_API_KEY=
|
||||||
|
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
|
||||||
|
|
||||||
|
# Optional Stable Diffusion tuning (defaults in src/lib/server/runpod/endpoints.ts):
|
||||||
|
# RUNPOD_SD_STEPS=35
|
||||||
|
# RUNPOD_SD_STRENGTH=0.8
|
||||||
|
# RUNPOD_SD_GUIDANCE=7
|
||||||
|
# RUNPOD_SD_NEGATIVE_PROMPT=
|
||||||
|
|
||||||
|
# --- Client-side capability switches (NEXT_PUBLIC_, inlined at BUILD time) ---
|
||||||
|
# Each must be the literal string "true" to enable; anything else is off. Because
|
||||||
|
# they are inlined at build time, changing one requires a rebuild, not a restart.
|
||||||
|
#
|
||||||
|
# false/unset = object detection runs FREE in-browser (COCO-SSD). true = use the
|
||||||
|
# RunPod YOLO classifier via /api/gallery/ai/classify (COCO-SSD stays the fallback).
|
||||||
|
# NEXT_PUBLIC_APG_RUNPOD_DETECT=false
|
||||||
|
#
|
||||||
|
# false/unset = background removal runs in-browser (@imgly WASM, no key).
|
||||||
|
# true = use the RunPod U²-Net endpoint, falling back to @imgly on failure.
|
||||||
|
NEXT_PUBLIC_APG_RUNPOD_BG=true
|
||||||
|
#
|
||||||
|
# true = show the editor's Auto-straighten button and call /api/gallery/ai/tilt.
|
||||||
|
# Only enable this if RUNPOD_TILT_URL is actually deployed.
|
||||||
|
# NEXT_PUBLIC_APG_RUNPOD_TILT=false
|
||||||
|
|
||||||
|
# NOTE: the photo-gallery SDK also reads a family of NEXT_PUBLIC_APG_* THEMING
|
||||||
|
# vars (NEXT_PUBLIC_APG_THEME / _ACCENT / _RADIUS / _BG_DARK / _SIDEBAR_BG_* …)
|
||||||
|
# in its standalone demo. Those are NOT used here — the CRM passes `themeTokens`
|
||||||
|
# to the gallery component directly so the gallery inherits the dashboard's
|
||||||
|
# design tokens. Setting them in this file has no effect.
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Smart Gallery — AI route surface
|
||||||
|
|
||||||
|
The Smart Gallery embeds `@photo-gallery/sdk` into the CRM. The SDK never talks to a
|
||||||
|
model directly: it calls a pluggable `AIProvider`, which the CRM supplies via
|
||||||
|
`createCrmAIProvider()` in [`src/lib/gallery-ai.ts`](../src/lib/gallery-ai.ts).
|
||||||
|
|
||||||
|
Roughly half the intelligence runs **in the browser** for free, and the other half is
|
||||||
|
proxied through **five server routes** under `/api/gallery/ai/*` so the RunPod API key
|
||||||
|
never reaches the client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Where each capability runs
|
||||||
|
|
||||||
|
| Capability | Where | Backend | Needs a key? |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Object detection (default) | Browser | TensorFlow.js COCO-SSD (80 COCO classes) | No |
|
||||||
|
| Object detection (opt-in) | **Server** | RunPod YOLO → `/classify` | Yes |
|
||||||
|
| Face detection + recognition | Browser | `@vladmandic/face-api` (128-D descriptors → People) | No |
|
||||||
|
| OCR / document search | Browser | `tesseract.js` | No |
|
||||||
|
| Semantic ("beach photos") search | Browser | CLIP via `@huggingface/transformers` | No |
|
||||||
|
| Background removal (default) | Browser | `@imgly/background-removal` (WASM) | No |
|
||||||
|
| Background removal (opt-in) | **Server** | RunPod U²-Net → `/edit` | Yes |
|
||||||
|
| Generative edits, restore, upscale, outpaint | **Server** | `/edit` | Yes |
|
||||||
|
| Speech-to-text | **Server** | `/transcribe` | Yes |
|
||||||
|
| Audio denoise | **Server** | `/denoise` | Yes |
|
||||||
|
| Camera tilt / auto-straighten | **Server** | `/tilt` | Yes |
|
||||||
|
|
||||||
|
Every in-browser model is loaded with `await import(...)` on first use, so none of it
|
||||||
|
lands in the initial bundle. **Every capability degrades gracefully** — a model that
|
||||||
|
fails to load returns `[]` / `''` / `null` with a `console.warn` rather than throwing,
|
||||||
|
so a blocked CDN never breaks the gallery UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auth model
|
||||||
|
|
||||||
|
All five routes share one gate: `requireGallerySession()` in
|
||||||
|
[`src/lib/server/session.ts`](../src/lib/server/session.ts).
|
||||||
|
|
||||||
|
These routes proxy a **paid, rate-limited GPU backend** using a secret held only on the
|
||||||
|
server. An unauthenticated route here is not just an information leak — it is an open
|
||||||
|
invitation to spend the operator's GPU budget. (The upstream SDK demo's routes are
|
||||||
|
completely unauthenticated; that is the single biggest thing this port fixes.)
|
||||||
|
|
||||||
|
**When the Shell is configured** (`NEXT_PUBLIC_SUPABASE_URL` is set): the incoming
|
||||||
|
`cookie` header is forwarded to `${BFF_ORIGIN}/api/session/context` and only a `200`
|
||||||
|
is accepted.
|
||||||
|
|
||||||
|
- `401` upstream → `401 { error: "Not signed in" }`
|
||||||
|
- any other non-200, or a network/timeout failure → `503 { error: "Session service unavailable" }`
|
||||||
|
(**fail closed** — if we cannot prove a session, we do not spend GPU budget)
|
||||||
|
- **Nothing is cached.** A cached "yes" would keep a revoked session alive, so every AI
|
||||||
|
request costs one BFF round trip. Correctness over latency for a spend gate.
|
||||||
|
- The BFF is trusted absolutely — `BFF_ORIGIN` must only ever point at an origin the
|
||||||
|
operator controls.
|
||||||
|
|
||||||
|
**When the Shell is NOT configured** (local demo mode): the request is allowed and a
|
||||||
|
warning is logged once. **Never deploy to a public origin with the Shell unconfigured
|
||||||
|
and a real `RUNPOD_API_KEY` present** — that combination is an open, billable endpoint.
|
||||||
|
|
||||||
|
This is authentication only, not authorization. Per-resource gallery policy lives in
|
||||||
|
be-crm behind the `crm.gallery` resource.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Rate limits
|
||||||
|
|
||||||
|
[`src/lib/server/rate-limit.ts`](../src/lib/server/rate-limit.ts) — a fixed-window
|
||||||
|
counter keyed by the authenticated principal when known, otherwise the first hop of
|
||||||
|
`x-forwarded-for`. Each route has its own namespace, so spending your `edit` budget
|
||||||
|
does not consume your `classify` budget. Exceeding it returns
|
||||||
|
`429 { error: "Too many requests — slow down." }` with a `Retry-After` header.
|
||||||
|
|
||||||
|
| Route | Limit |
|
||||||
|
| --- | --- |
|
||||||
|
| `/classify` | 30 / min |
|
||||||
|
| `/tilt` | 30 / min |
|
||||||
|
| `/transcribe` | 20 / min |
|
||||||
|
| `/denoise` | 20 / min |
|
||||||
|
| `/edit` | 12 / min (most expensive) |
|
||||||
|
|
||||||
|
> **This limiter is per-instance and in-memory.** With N instances behind a load
|
||||||
|
> balancer a caller gets up to N x the budget, and a restart clears all counters. It is
|
||||||
|
> a cost guard, not a security boundary. **Replace it with Redis before running more
|
||||||
|
> than one instance.** The `x-forwarded-for` key is also client-controlled unless a
|
||||||
|
> trusted proxy overwrites it — the session gate, not this, is the security boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The routes
|
||||||
|
|
||||||
|
All five are `POST` only, and all declare `runtime = "nodejs"`, `maxDuration = 60`,
|
||||||
|
`dynamic = "force-dynamic"`. Failures always return `{ error: string }`.
|
||||||
|
|
||||||
|
Shared status codes: `400` invalid body/params · `401` not signed in · `413` payload too
|
||||||
|
large · `429` rate limited · `500` server misconfigured (e.g. missing `RUNPOD_API_KEY`) ·
|
||||||
|
`502` upstream failed · `503` not configured / session service unavailable ·
|
||||||
|
`504` upstream timed out (the RunPod client's budget is 55s, under the 60s cap).
|
||||||
|
|
||||||
|
Upstream error bodies are truncated to a **160-character excerpt**; the RunPod key is
|
||||||
|
never included in any response.
|
||||||
|
|
||||||
|
### `POST /api/gallery/ai/classify` — object detection
|
||||||
|
Backed by `RUNPOD_YOLO_URL`. Max ~4 MB of base64. Returns boxes as **fractions 0..1**
|
||||||
|
of the image, matching the SDK's `DetectedObject`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// request
|
||||||
|
{ "imageBase64": "…", "width": 1280, "height": 853 }
|
||||||
|
// response
|
||||||
|
{ "objects": [ { "label": "excavator", "confidence": 0.91,
|
||||||
|
"box": { "x": 0.12, "y": 0.30, "width": 0.25, "height": 0.40 } } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/gallery/ai/edit` — generative image editing
|
||||||
|
The backend is pluggable via `AI_EDIT_PROVIDER` (`auto` | `runpod` | `local` |
|
||||||
|
`huggingface` | `gemini` | `none`). Image + mask are budgeted **together** against the
|
||||||
|
~4 MB cap. Prompts come from an allow-listed op set — arbitrary server-side prompts are
|
||||||
|
never accepted, and free-text is clamped to 500 chars.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// request
|
||||||
|
{ "imageBase64": "…", "mimeType": "image/jpeg",
|
||||||
|
"op": { "type": "restore" }, // or prompt | colorize | replace-sky |
|
||||||
|
// magic-eraser | generative-fill | upscale |
|
||||||
|
// remove-background
|
||||||
|
"maskBase64": "…", // required for magic-eraser / generative-fill
|
||||||
|
"params": { "strength": 0.8 } }
|
||||||
|
// response
|
||||||
|
{ "imageBase64": "…", "mimeType": "image/png" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Op → RunPod endpoint: `restore`/`upscale` → `RUNPOD_UPSCALE_URL` ·
|
||||||
|
`prompt`/`colorize` → `RUNPOD_SD_IMG2IMG_URL` · `replace-sky`/`magic-eraser`/
|
||||||
|
`generative-fill` → `RUNPOD_SD_INPAINT_URL` · `remove-background` →
|
||||||
|
`RUNPOD_BG_REMOVE_URL`. `upscale`, `magic-eraser` and `generative-fill` are RunPod-only
|
||||||
|
and return `400` under another backend. Outpaint is client-side padding plus a
|
||||||
|
`generative-fill` call — it needs no separate route.
|
||||||
|
|
||||||
|
### `POST /api/gallery/ai/tilt` — camera tilt
|
||||||
|
Backed by `RUNPOD_TILT_URL`. Max ~4 MB (`413` over). Only reachable when
|
||||||
|
`NEXT_PUBLIC_APG_RUNPOD_TILT=true`, which also reveals the editor's Auto-straighten button.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "image": "…" } → { "rollDegrees": -2.4, "pitchDegrees": 1.1, "fovDegrees": 68.2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/gallery/ai/transcribe` — speech to text
|
||||||
|
Backed by `RUNPOD_STT_URL`. Expects base64 **WAV 16 kHz mono PCM16**. Max ~8 MB (`413`).
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "audio": "…", "language": "en" }
|
||||||
|
→ { "transcript": "…", "segments": [ { "text": "…", "startSec": 0, "endSec": 1.8 } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/gallery/ai/denoise` — audio noise removal
|
||||||
|
Backed by `RUNPOD_AUDIO_DENOISE_URL`. Expects base64 **WAV 48 kHz mono PCM16**. Max
|
||||||
|
~12 MB (`413`). Used before transcription on noisy sites.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "audio": "…" } → { "audio": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Environment variables
|
||||||
|
|
||||||
|
See [`.env.local.example`](../.env.local.example) for the fully commented template.
|
||||||
|
|
||||||
|
| Var | Backs |
|
||||||
|
| --- | --- |
|
||||||
|
| `RUNPOD_API_KEY` | all five routes (server-only — never `NEXT_PUBLIC_`) |
|
||||||
|
| `RUNPOD_YOLO_URL` | `/classify` |
|
||||||
|
| `RUNPOD_SD_IMG2IMG_URL` | `/edit` — prompt, colorize, maskless replace-sky |
|
||||||
|
| `RUNPOD_SD_INPAINT_URL` | `/edit` — magic-eraser, generative-fill, outpaint, masked replace-sky |
|
||||||
|
| `RUNPOD_UPSCALE_URL` | `/edit` — restore, upscale |
|
||||||
|
| `RUNPOD_BG_REMOVE_URL` | `/edit` — remove-background |
|
||||||
|
| `RUNPOD_STT_URL` | `/transcribe` |
|
||||||
|
| `RUNPOD_AUDIO_DENOISE_URL` | `/denoise` |
|
||||||
|
| `RUNPOD_TILT_URL` | `/tilt` |
|
||||||
|
| `AI_EDIT_PROVIDER` | which backend `/edit` uses |
|
||||||
|
| `BFF_ORIGIN` | the session gate |
|
||||||
|
|
||||||
|
Client switches (`NEXT_PUBLIC_`, **inlined at build time** — a change needs a rebuild,
|
||||||
|
and each must be the literal string `"true"`):
|
||||||
|
`NEXT_PUBLIC_APG_RUNPOD_DETECT`, `NEXT_PUBLIC_APG_RUNPOD_BG`, `NEXT_PUBLIC_APG_RUNPOD_TILT`.
|
||||||
|
|
||||||
|
> The SDK's standalone demo also reads a family of `NEXT_PUBLIC_APG_*` **theming** vars
|
||||||
|
> (`_THEME`, `_ACCENT`, `_RADIUS`, `_BG_DARK`, `_SIDEBAR_BG_*`, …). Those are **not used
|
||||||
|
> here** — the CRM passes `themeTokens` to the gallery directly so it inherits the
|
||||||
|
> dashboard's design tokens. Setting them in this app has no effect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CSP / CDN hosts
|
||||||
|
|
||||||
|
**The CRM ships no Content-Security-Policy today**, so the in-browser models load
|
||||||
|
without any allow-listing. This section is what would need to be permitted **if a CSP
|
||||||
|
is ever added** — miss any of these and the affected capability silently degrades
|
||||||
|
(returns empty + `console.warn`) rather than erroring visibly, which makes it easy to
|
||||||
|
misdiagnose.
|
||||||
|
|
||||||
|
| Host | Needed by | Directive |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `https://cdn.jsdelivr.net` | face-api models; tesseract worker, WASM core **and** language data | `script-src`, `connect-src`, `worker-src` |
|
||||||
|
| `https://huggingface.co`, `https://cdn-lfs.huggingface.co` | CLIP model weights (transformers.js) | `connect-src` |
|
||||||
|
| `https://storage.googleapis.com` | TensorFlow.js COCO-SSD model | `connect-src` |
|
||||||
|
| `https://staticimgly.com` | `@imgly/background-removal` WASM + assets | `connect-src`, `worker-src` |
|
||||||
|
|
||||||
|
Also required by the ML runtimes themselves:
|
||||||
|
|
||||||
|
- **`wasm-unsafe-eval` in `script-src`** — ONNX Runtime (CLIP), tesseract-core and the
|
||||||
|
`@imgly` remover are all WebAssembly. Without it, semantic search, OCR and in-browser
|
||||||
|
background removal all fail.
|
||||||
|
- **`blob:` in `worker-src`/`child_src`** — tesseract and the WASM runtimes spawn
|
||||||
|
workers from blob URLs.
|
||||||
|
- **`data:` and `blob:` in `img-src`** — canvas round-trips and generated results.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- All three tesseract assets (worker, core, tessdata) are pinned to **jsDelivr** on
|
||||||
|
purpose. Left at its defaults, tesseract fetches language data from
|
||||||
|
`tessdata.projectnaptha.com`, which would be a second host to allow-list.
|
||||||
|
- `tesseract.js` is pinned to **exactly `5.1.1`** (no caret) in `package.json` because
|
||||||
|
the worker CDN URL embeds the version — a floating range would let the worker drift
|
||||||
|
out of sync with the installed main-thread code.
|
||||||
|
- The tfjs providers prefer the **WebGL** backend, which avoids `eval` and is therefore
|
||||||
|
CSP-friendly; they fall back to the default backend if WebGL is unavailable.
|
||||||
|
- The SDK's own demo app runs a strict per-request nonce CSP with these hosts already
|
||||||
|
allow-listed — see that repo's middleware for a working reference.
|
||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// The SDKs ship ESM; let Next transpile them.
|
// The SDKs ship ESM/TS source (their `exports` point at src/), so Next must
|
||||||
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui"],
|
// transpile them rather than treat them as prebuilt CJS.
|
||||||
|
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui", "@photo-gallery/sdk"],
|
||||||
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
|
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
|
||||||
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
|
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
|
||||||
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
|
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
"@abe-kap/appshell-sdk": "^0.2.6",
|
||||||
"@insignia/iios-kernel-client": "^0.1.4",
|
"@insignia/iios-kernel-client": "^0.1.4",
|
||||||
"@insignia/iios-messaging-ui": "^0.1.2",
|
"@insignia/iios-messaging-ui": "^0.1.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
@@ -1028,9 +1028,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@insignia/iios-messaging-ui": {
|
"node_modules/@insignia/iios-messaging-ui": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.2/iios-messaging-ui-0.1.2.tgz",
|
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.5/iios-messaging-ui-0.1.5.tgz",
|
||||||
"integrity": "sha512-YsoTM9vmjQ+dk6Hch5nXuqMFAdybd3luAZepfaliOw727ylc8FPAtwTMMcIWQ3WOQPa2eKeGLiEpo61QCO0GrA==",
|
"integrity": "sha512-3Ib72RsyyOEMIFl2MquAwdocbeYOMpg4XkSLaje8rmgche+QJSRnJd3hzZyhv89VBolxPxNZw+Sz6bN9J0gp9Q==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@insignia/iios-kernel-client": "*",
|
"@insignia/iios-kernel-client": "*",
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
|
|||||||
+13
-3
@@ -6,21 +6,31 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"sync:gallery-sdk": "node scripts/sync-gallery-sdk.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
"@abe-kap/appshell-sdk": "^0.2.6",
|
||||||
|
"@huggingface/transformers": "^4.2.0",
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
"@insignia/iios-kernel-client": "^0.1.4",
|
"@insignia/iios-kernel-client": "^0.1.4",
|
||||||
"@insignia/iios-messaging-ui": "^0.1.2",
|
"@insignia/iios-messaging-ui": "^0.1.4",
|
||||||
|
"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk",
|
||||||
|
"@tensorflow-models/coco-ssd": "^2.2.3",
|
||||||
|
"@tensorflow/tfjs": "^4.22.0",
|
||||||
|
"@vladmandic/face-api": "^1.7.15",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tesseract.js": "5.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Re-sync the vendored @photo-gallery/sdk from the sibling SDK checkout.
|
||||||
|
*
|
||||||
|
* The SDK is not published to a registry yet, so it is vendored into `vendor/photo-gallery-sdk`
|
||||||
|
* and depended on as `file:./vendor/photo-gallery-sdk` — a path INSIDE this repo, so CI and Vercel
|
||||||
|
* (which only ever check out this repo) can resolve it. See vendor/README.md.
|
||||||
|
*
|
||||||
|
* npm run sync:gallery-sdk
|
||||||
|
* git add vendor/photo-gallery-sdk && git commit -m "chore: sync @photo-gallery/sdk"
|
||||||
|
*
|
||||||
|
* Only what the package would publish is copied. Its `node_modules` is deliberately left behind:
|
||||||
|
* without it the SDK's sources resolve `@types/react` from this repo (React 19) instead of the
|
||||||
|
* pnpm workspace's React 18, which is what lets them type-check here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const source =
|
||||||
|
process.env.GALLERY_SDK_PATH ??
|
||||||
|
resolve(root, "../advance-photo-gallery-web-sdk/packages/photo-sdk");
|
||||||
|
const target = resolve(root, "vendor/photo-gallery-sdk");
|
||||||
|
|
||||||
|
const ENTRIES = ["src", "package.json", "README.md"];
|
||||||
|
|
||||||
|
const exists = async (p) => {
|
||||||
|
try {
|
||||||
|
await stat(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(await exists(source))) {
|
||||||
|
console.error(`[sync:gallery-sdk] SDK checkout not found at ${source}`);
|
||||||
|
console.error(" Clone advance-photo-gallery-web-sdk beside this repo, or set GALLERY_SDK_PATH.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of ENTRIES) {
|
||||||
|
const from = resolve(source, entry);
|
||||||
|
if (!(await exists(from))) continue;
|
||||||
|
const to = resolve(target, entry);
|
||||||
|
await rm(to, { recursive: true, force: true });
|
||||||
|
await cp(from, to, { recursive: true });
|
||||||
|
console.log(`[sync:gallery-sdk] ${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Strip devDependencies + scripts from the vendored manifest.
|
||||||
|
*
|
||||||
|
* npm never installs a published package's devDependencies, but it DOES install them for a local
|
||||||
|
* `file:` path package. Left in place, the SDK's `@types/react@18` lands in
|
||||||
|
* vendor/photo-gallery-sdk/node_modules and its sources then type-check against React 18 inside a
|
||||||
|
* React 19 program — "Type 'bigint' is not assignable to type 'ReactNode'". Removing them makes the
|
||||||
|
* vendored copy behave exactly like the tarball it stands in for.
|
||||||
|
*/
|
||||||
|
const manifestPath = resolve(target, "package.json");
|
||||||
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||||
|
delete manifest.devDependencies;
|
||||||
|
delete manifest.scripts;
|
||||||
|
manifest._vendored = {
|
||||||
|
from: "advance-photo-gallery-web-sdk/packages/photo-sdk",
|
||||||
|
note: "Generated by scripts/sync-gallery-sdk.mjs — do not hand-edit. See vendor/README.md.",
|
||||||
|
};
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||||
|
console.log("[sync:gallery-sdk] package.json (devDependencies + scripts stripped)");
|
||||||
|
|
||||||
|
console.log("[sync:gallery-sdk] done — commit vendor/photo-gallery-sdk, then restart the dev server.");
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Shared entry gate for every /api/gallery/ai/* route: authenticate, then
|
||||||
|
* throttle. Lives in a `_`-prefixed file so the App Router never treats it as a
|
||||||
|
* route (only `route.ts` defines an endpoint).
|
||||||
|
*
|
||||||
|
* Order matters: we authenticate FIRST so the rate limit can be keyed by
|
||||||
|
* principal rather than by a spoofable `x-forwarded-for` hop wherever possible.
|
||||||
|
* The session check is one cheap BFF round trip; the work it guards is a GPU
|
||||||
|
* call, so paying it before throttling is the right trade.
|
||||||
|
*
|
||||||
|
* SERVER-ONLY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { limit } from "@/lib/server/rate-limit";
|
||||||
|
import { rateLimitKey, requireGallerySession } from "@/lib/server/session";
|
||||||
|
|
||||||
|
/** Per-minute budgets, per the Smart Gallery route contract. */
|
||||||
|
export const RATE_LIMITS = {
|
||||||
|
classify: 30,
|
||||||
|
edit: 12,
|
||||||
|
tilt: 30,
|
||||||
|
transcribe: 20,
|
||||||
|
denoise: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
export type GuardResult =
|
||||||
|
| { ok: true; principalId?: string }
|
||||||
|
/** Ready-to-return error response — the route should return it unchanged. */
|
||||||
|
| { ok: false; response: NextResponse };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param route Which budget to apply (also namespaces the limiter key so a
|
||||||
|
* caller's `edit` spend does not consume their `classify` budget).
|
||||||
|
*/
|
||||||
|
export async function guard(req: Request, route: keyof typeof RATE_LIMITS): Promise<GuardResult> {
|
||||||
|
const session = await requireGallerySession(req);
|
||||||
|
if (!session.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json({ error: session.error }, { status: session.status }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${route}:${rateLimitKey(req, session.principalId)}`;
|
||||||
|
const { ok, retryAfter } = limit(key, RATE_LIMITS[route], WINDOW_MS);
|
||||||
|
if (!ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json(
|
||||||
|
{ error: "Too many requests — slow down." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, principalId: session.principalId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { RunpodError } from "@/lib/server/runpod/client";
|
||||||
|
import { rpDetect } from "@/lib/server/runpod/endpoints";
|
||||||
|
|
||||||
|
import { guard } from "../_guard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object-detection proxy for the RunPod YOLO construction-material classifier (#1).
|
||||||
|
* The key + endpoint URL stay server-side. The client calls this only when
|
||||||
|
* NEXT_PUBLIC_APG_RUNPOD_DETECT is on; otherwise detection runs fully in-browser
|
||||||
|
* (COCO-SSD) with no server round-trip. Returns the SDK's DetectedObject[] shape
|
||||||
|
* (box as 0..1 fractions) so it drops straight into the Objects browser / smart
|
||||||
|
* albums / search.
|
||||||
|
*
|
||||||
|
* POST { imageBase64, width, height } -> { objects: [{ label, confidence, box }] }
|
||||||
|
* Auth: session-gated (see lib/server/session.ts). Rate limit: 30/min.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — under serverless body limits
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await guard(req, "classify");
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
|
||||||
|
if (!process.env.RUNPOD_API_KEY || !process.env.RUNPOD_YOLO_URL) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "RunPod detection is not configured (set RUNPOD_API_KEY + RUNPOD_YOLO_URL)." },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageBase64, width, height } = (body ?? {}) as {
|
||||||
|
imageBase64?: unknown;
|
||||||
|
width?: unknown;
|
||||||
|
height?: unknown;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof imageBase64 !== "string" ||
|
||||||
|
imageBase64.length === 0 ||
|
||||||
|
imageBase64.length > MAX_BASE64
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Invalid or oversized image." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const w = Number(width);
|
||||||
|
const h = Number(height);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const objects = await rpDetect(
|
||||||
|
imageBase64,
|
||||||
|
Number.isFinite(w) && w > 0 ? w : 1,
|
||||||
|
Number.isFinite(h) && h > 0 ? h : 1,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ objects });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Detection failed.";
|
||||||
|
const status = err instanceof RunpodError ? err.status : 502;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { RunpodError } from "@/lib/server/runpod/client";
|
||||||
|
import { rpDenoiseAudio } from "@/lib/server/runpod/endpoints";
|
||||||
|
|
||||||
|
import { guard } from "../_guard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60; // cold-start denoise worker can take a while
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio noise-removal proxy. Accepts base64 WAV (48 kHz mono PCM16, produced
|
||||||
|
* in-browser) and returns a cleaned base64 WAV. Calls the RunPod audio-denoise
|
||||||
|
* endpoint (RUNPOD_AUDIO_DENOISE_URL) — key stays server-side. Used before
|
||||||
|
* transcription on noisy sites.
|
||||||
|
*
|
||||||
|
* POST { audio } -> { audio }
|
||||||
|
* Auth: session-gated. Rate limit: 20/min.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_BASE64 = 12_000_000; // ~9 MB decoded WAV
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await guard(req, "denoise");
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
|
||||||
|
let body: { audio?: unknown };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = typeof body.audio === "string" ? body.audio : "";
|
||||||
|
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
|
||||||
|
if (audio.length > MAX_BASE64) {
|
||||||
|
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { audioB64 } = await rpDenoiseAudio(audio);
|
||||||
|
return NextResponse.json({ audio: audioB64 });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Denoise failed.";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { RunpodError } from "@/lib/server/runpod/client";
|
||||||
|
import {
|
||||||
|
rpImg2Img,
|
||||||
|
rpInpaint,
|
||||||
|
rpRemoveBackground,
|
||||||
|
rpUpscale,
|
||||||
|
} from "@/lib/server/runpod/endpoints";
|
||||||
|
|
||||||
|
import { guard } from "../_guard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60; // SD / cold-start models can take a while
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generative image-edit proxy. The BACKEND is pluggable — pick one with env
|
||||||
|
* `AI_EDIT_PROVIDER` (default `auto`):
|
||||||
|
*
|
||||||
|
* - `runpod` → RunPod serverless GPU endpoints (one per model). Maps each
|
||||||
|
* op → endpoint: restore/upscale → Real-ESRGAN (#7), colorize
|
||||||
|
* → img2img (#10), replace-sky / magic-eraser / generative-fill
|
||||||
|
* → SD 3.5 masked inpaint (#9), prompt → SD 3.5 img2img (#10).
|
||||||
|
* Env: RUNPOD_API_KEY + per-model RUNPOD_*_URL. Key stays
|
||||||
|
* server-side.
|
||||||
|
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
|
||||||
|
* SD.Next img2img API). Env: LOCAL_SD_URL.
|
||||||
|
* - `huggingface` → Hugging Face Inference API. Env: HF_API_TOKEN, HF_IMAGE_MODEL.
|
||||||
|
* - `gemini` → Google Gemini image model (needs a billed key for image output).
|
||||||
|
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
|
||||||
|
* - `auto` → first configured of: runpod → local → huggingface → gemini.
|
||||||
|
*
|
||||||
|
* NOTE: `remove-background` runs in-browser by default (@imgly, no key), so it
|
||||||
|
* usually never reaches here. Object detection uses its own route (./classify).
|
||||||
|
*
|
||||||
|
* POST { imageBase64, mimeType?, op, maskBase64?, params? } -> { imageBase64, mimeType }
|
||||||
|
* Auth: session-gated. Rate limit: 12/min (the most expensive route).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
type Provider = "runpod" | "local" | "huggingface" | "gemini" | "none";
|
||||||
|
|
||||||
|
function resolveProvider(): Provider {
|
||||||
|
const explicit = (process.env.AI_EDIT_PROVIDER || "auto").toLowerCase();
|
||||||
|
if (
|
||||||
|
explicit === "runpod" ||
|
||||||
|
explicit === "local" ||
|
||||||
|
explicit === "huggingface" ||
|
||||||
|
explicit === "gemini"
|
||||||
|
)
|
||||||
|
return explicit;
|
||||||
|
if (explicit === "none") return "none";
|
||||||
|
// auto: prefer RunPod GPU endpoints, then a private local server, then HF, then Gemini.
|
||||||
|
// Detect RunPod when the key + ANY image endpoint URL is set (an upscale/colorize-only
|
||||||
|
// deployment is valid — not just the SD ones).
|
||||||
|
if (
|
||||||
|
process.env.RUNPOD_API_KEY &&
|
||||||
|
(process.env.RUNPOD_SD_IMG2IMG_URL ||
|
||||||
|
process.env.RUNPOD_SD_INPAINT_URL ||
|
||||||
|
process.env.RUNPOD_UPSCALE_URL ||
|
||||||
|
process.env.RUNPOD_COLORIZE_URL ||
|
||||||
|
process.env.RUNPOD_BG_REMOVE_URL)
|
||||||
|
)
|
||||||
|
return "runpod";
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops that only the RunPod (mask/fixed-function) backend can serve. */
|
||||||
|
const RUNPOD_ONLY_OPS = new Set(["upscale", "magic-eraser", "generative-fill"]);
|
||||||
|
|
||||||
|
interface EditResult {
|
||||||
|
imageBase64: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await guard(req, "edit");
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
|
||||||
|
const provider = resolveProvider();
|
||||||
|
if (provider === "none") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"AI image editing is not configured. Set AI_EDIT_PROVIDER=runpod + RUNPOD_API_KEY + the per-model RUNPOD_*_URL vars (RunPod GPU), or LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key.",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageBase64, mimeType, op, maskBase64, params } = (body ?? {}) as {
|
||||||
|
imageBase64?: unknown;
|
||||||
|
mimeType?: unknown;
|
||||||
|
op?: { type?: string; prompt?: string; factor?: number };
|
||||||
|
maskBase64?: unknown;
|
||||||
|
params?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Invalid image." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const hasMask = typeof maskBase64 === "string" && maskBase64.length > 0;
|
||||||
|
// Image + mask share one request body — budget them together against the cap.
|
||||||
|
if (imageBase64.length + (hasMask ? (maskBase64 as string).length : 0) > MAX_BASE64) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Image (plus mask) is too large — try a smaller image." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const safeMime =
|
||||||
|
typeof mimeType === "string" && /^image\/(jpeg|png|webp)$/.test(mimeType)
|
||||||
|
? mimeType
|
||||||
|
: "image/jpeg";
|
||||||
|
|
||||||
|
const opType = op?.type ?? "";
|
||||||
|
if (provider !== "runpod" && RUNPOD_ONLY_OPS.has(opType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "This edit needs the RunPod backend (set AI_EDIT_PROVIDER=runpod)." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
|
||||||
|
let instruction = "";
|
||||||
|
if (opType === "prompt" || opType === "generative-fill") {
|
||||||
|
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 (opType === "replace-sky") {
|
||||||
|
instruction =
|
||||||
|
typeof op?.prompt === "string" && op.prompt.trim()
|
||||||
|
? `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`
|
||||||
|
: OP_PROMPTS["replace-sky"]!;
|
||||||
|
} else if (opType === "magic-eraser") {
|
||||||
|
instruction =
|
||||||
|
"Fill the selected region with a clean, seamless, plausible background. Photorealistic.";
|
||||||
|
} else if (OP_PROMPTS[opType]) {
|
||||||
|
instruction = OP_PROMPTS[opType]!;
|
||||||
|
} else if (opType !== "upscale" && opType !== "remove-background") {
|
||||||
|
return NextResponse.json({ error: "Unsupported operation." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: EditResult;
|
||||||
|
if (provider === "runpod")
|
||||||
|
result = await editRunPod(
|
||||||
|
op ?? {},
|
||||||
|
imageBase64,
|
||||||
|
instruction,
|
||||||
|
hasMask ? (maskBase64 as string) : undefined,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
else 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 instanceof RunpodError ? err.status : 502;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backend: RunPod serverless GPU endpoints (one model per endpoint).
|
||||||
|
// Each op maps to its endpoint; the API key + URLs stay server-side.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface SdParams {
|
||||||
|
negativePrompt?: string;
|
||||||
|
strength?: number;
|
||||||
|
steps?: number;
|
||||||
|
seed?: number;
|
||||||
|
guidanceScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeParams(raw: unknown): SdParams {
|
||||||
|
const p = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const out: SdParams = {};
|
||||||
|
if (typeof p.negativePrompt === "string" && p.negativePrompt.trim())
|
||||||
|
out.negativePrompt = p.negativePrompt.trim().slice(0, 300);
|
||||||
|
const strength = Number(p.strength);
|
||||||
|
if (Number.isFinite(strength)) out.strength = Math.max(0, Math.min(1, strength));
|
||||||
|
const steps = Number(p.steps);
|
||||||
|
if (Number.isFinite(steps)) out.steps = Math.max(1, Math.min(60, Math.round(steps)));
|
||||||
|
const guidance = Number(p.guidanceScale);
|
||||||
|
if (Number.isFinite(guidance)) out.guidanceScale = Math.max(1, Math.min(20, guidance));
|
||||||
|
const seed = Number(p.seed);
|
||||||
|
if (Number.isFinite(seed)) out.seed = Math.max(0, Math.min(2_147_483_647, Math.round(seed)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editRunPod(
|
||||||
|
op: { type?: string; prompt?: string; factor?: number },
|
||||||
|
imageBase64: string,
|
||||||
|
instruction: string,
|
||||||
|
maskBase64: string | undefined,
|
||||||
|
rawParams: unknown,
|
||||||
|
): Promise<EditResult> {
|
||||||
|
const params = sanitizeParams(rawParams);
|
||||||
|
switch (op.type) {
|
||||||
|
case "remove-background":
|
||||||
|
// U²-Net via rembg (#6) — a real endpoint replacing the flaky in-browser remover.
|
||||||
|
return rpRemoveBackground(imageBase64);
|
||||||
|
case "restore":
|
||||||
|
// Real-ESRGAN (#7) with the GFPGAN face pass = "Restore & Enhance".
|
||||||
|
return rpUpscale(imageBase64, 4, true);
|
||||||
|
case "upscale":
|
||||||
|
return rpUpscale(imageBase64, op.factor === 4 ? 4 : 2, false);
|
||||||
|
case "colorize":
|
||||||
|
// The dedicated DDColor endpoint kept hard-crashing (modelscope). Route
|
||||||
|
// colorize through the img2img model as an instruction instead.
|
||||||
|
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params });
|
||||||
|
case "prompt":
|
||||||
|
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params }); // SD 3.5 img2img (#10)
|
||||||
|
case "replace-sky":
|
||||||
|
// True sky replacement is masked inpaint (#9). Without a mask (no in-app sky
|
||||||
|
// segmentation yet) degrade to a low-strength img2img (#10) so the foreground
|
||||||
|
// is mostly preserved.
|
||||||
|
if (maskBase64)
|
||||||
|
return rpInpaint({
|
||||||
|
imageB64: imageBase64,
|
||||||
|
maskB64: maskBase64,
|
||||||
|
prompt: instruction,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
return rpImg2Img({
|
||||||
|
imageB64: imageBase64,
|
||||||
|
prompt: instruction,
|
||||||
|
...params,
|
||||||
|
strength: params.strength ?? 0.4,
|
||||||
|
});
|
||||||
|
case "magic-eraser":
|
||||||
|
case "generative-fill":
|
||||||
|
// SD 3.5 masked inpaint (#9) — white in the mask = the region to regenerate.
|
||||||
|
if (!maskBase64) throw new AiError("This edit needs a mask/selection.", 400);
|
||||||
|
return rpInpaint({
|
||||||
|
imageB64: imageBase64,
|
||||||
|
maskB64: maskBase64,
|
||||||
|
prompt: instruction,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new AiError("Unsupported operation.", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} 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 — 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 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 },
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new AiError("Could not reach the Hugging Face Inference API.", 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
// Truncated on purpose — never surface a full upstream body.
|
||||||
|
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||||
|
if (res.status === 503) throw new AiError("The 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"] },
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new AiError("Could not reach the AI service.", 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
// Truncated on purpose — never surface a full upstream body.
|
||||||
|
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,48 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { RunpodError } from "@/lib/server/runpod/client";
|
||||||
|
import { rpTilt } from "@/lib/server/runpod/endpoints";
|
||||||
|
|
||||||
|
import { guard } from "../_guard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60; // cold-start tilt worker can take a while
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera-tilt estimation proxy. Accepts a base64 image and returns
|
||||||
|
* {rollDegrees, pitchDegrees, fovDegrees} from the RunPod tilt endpoint
|
||||||
|
* (RUNPOD_TILT_URL) so the editor can auto-straighten.
|
||||||
|
*
|
||||||
|
* POST { image } -> { rollDegrees, pitchDegrees, fovDegrees }
|
||||||
|
* Auth: session-gated. Rate limit: 30/min.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_BASE64 = 4_000_000; // ~3 MB decoded
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await guard(req, "tilt");
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
|
||||||
|
let body: { image?: unknown };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = typeof body.image === "string" ? body.image : "";
|
||||||
|
if (!image) return NextResponse.json({ error: "Missing image." }, { status: 400 });
|
||||||
|
if (image.length > MAX_BASE64) {
|
||||||
|
return NextResponse.json({ error: "Image too large." }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tilt = await rpTilt(image);
|
||||||
|
return NextResponse.json(tilt);
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Tilt estimate failed.";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { RunpodError } from "@/lib/server/runpod/client";
|
||||||
|
import { rpTranscribe } from "@/lib/server/runpod/endpoints";
|
||||||
|
|
||||||
|
import { guard } from "../_guard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60; // cold-start STT worker can take a while
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speech-to-text proxy for voice annotations. Accepts base64 WAV (16 kHz mono
|
||||||
|
* PCM16, produced in-browser) and returns the transcript. Calls the RunPod
|
||||||
|
* voice-to-text endpoint (RUNPOD_STT_URL) — the key stays server-side.
|
||||||
|
*
|
||||||
|
* POST { audio, language? } -> { transcript, segments? }
|
||||||
|
* Auth: session-gated. Rate limit: 20/min.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_BASE64 = 8_000_000; // ~6 MB decoded WAV — stays under serverless body limits
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await guard(req, "transcribe");
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
|
||||||
|
let body: { audio?: unknown; language?: unknown };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = typeof body.audio === "string" ? body.audio : "";
|
||||||
|
const language = typeof body.language === "string" ? body.language : undefined;
|
||||||
|
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
|
||||||
|
if (audio.length > MAX_BASE64) {
|
||||||
|
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { transcript, segments } = await rpTranscribe(audio, { language, punctuation: true });
|
||||||
|
return NextResponse.json({ transcript, segments });
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Transcription failed.";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -447,10 +447,8 @@
|
|||||||
.dash-root .ds-modal.size-sm { max-width: 400px; }
|
.dash-root .ds-modal.size-sm { max-width: 400px; }
|
||||||
.dash-root .ds-modal.size-md { max-width: 540px; }
|
.dash-root .ds-modal.size-md { max-width: 540px; }
|
||||||
.dash-root .ds-modal.size-lg { max-width: 760px; }
|
.dash-root .ds-modal.size-lg { max-width: 760px; }
|
||||||
.dash-root .ds-modal.size-xl { max-width: 980px; }
|
|
||||||
.dash-root .ds-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 20px 14px; border-bottom: 1px solid var(--border); }
|
.dash-root .ds-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 20px 14px; border-bottom: 1px solid var(--border); }
|
||||||
.dash-root .ds-modal-head-l { display: flex; gap: 12px; align-items: center; }
|
.dash-root .ds-modal-head-l { display: flex; gap: 12px; align-items: center; }
|
||||||
.dash-root .ds-modal-head-r { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; }
|
|
||||||
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
|
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||||
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
|
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
|
||||||
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
|
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
|
||||||
@@ -924,7 +922,7 @@
|
|||||||
.dash-root .tm .ds-btn.v-primary { background: rgba(253, 169, 19, 0.92); color: #fff; border-radius: 9.132px; box-shadow: 0 8px 20px -10px rgba(253, 169, 19, 0.75); }
|
.dash-root .tm .ds-btn.v-primary { background: rgba(253, 169, 19, 0.92); color: #fff; border-radius: 9.132px; box-shadow: 0 8px 20px -10px rgba(253, 169, 19, 0.75); }
|
||||||
.dash-root .tm .ds-btn.v-primary svg { color: #fff; }
|
.dash-root .tm .ds-btn.v-primary svg { color: #fff; }
|
||||||
.dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); }
|
.dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); }
|
||||||
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.dash-root .tm-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 220px; height: 44px; padding: 0 13px; border-radius: 13px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); transition: 0.14s; }
|
.dash-root .tm-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 220px; height: 44px; padding: 0 13px; border-radius: 13px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); transition: 0.14s; }
|
||||||
.dash-root .tm-search:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px var(--ring); color: var(--orange); }
|
.dash-root .tm-search:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px var(--ring); color: var(--orange); }
|
||||||
.dash-root .tm-search .ds-input { flex: 1; }
|
.dash-root .tm-search .ds-input { flex: 1; }
|
||||||
@@ -1156,39 +1154,84 @@
|
|||||||
.dash-root .ai-caps { grid-template-columns: 1fr; }
|
.dash-root .ai-caps { grid-template-columns: 1fr; }
|
||||||
.dash-root .ai-bubble { max-width: 86%; }
|
.dash-root .ai-bubble { max-width: 86%; }
|
||||||
.dash-root .ai-view { height: calc(100vh - 150px); }
|
.dash-root .ai-view { height: calc(100vh - 150px); }
|
||||||
.dash-root .proj-row { grid-template-columns: 1fr !important; row-gap: 8px; padding: 14px 16px; }
|
|
||||||
.dash-root .proj-head { display: none; }
|
|
||||||
.dash-root .proj-c-act { justify-self: end; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Projects (Construction + Pipeline Leads) ---- */
|
/* =========================================================================
|
||||||
.dash-root .proj-toolbar .tm-tabs { flex: 0 0 auto; }
|
Smart Gallery — the embedded @photo-gallery/sdk surface.
|
||||||
.dash-root .proj-toolbar .tm-chips { flex: 0 0 auto; margin-left: auto; }
|
The SDK is themed entirely through the token map in lib/gallery-api.ts
|
||||||
.dash-root .proj-table { display: flex; flex-direction: column; }
|
(--apg-* -> this file's own vars), so the rules below only handle the
|
||||||
.dash-root .proj-row { display: grid; align-items: center; gap: 14px; padding: 13px 18px; border-bottom: 1px solid var(--border); }
|
host chrome: sizing, the demo banner, and the load/error placeholders.
|
||||||
.dash-root .proj-row:last-child { border-bottom: 0; }
|
========================================================================= */
|
||||||
.dash-root .proj-head { color: var(--faint); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; text-align: center; }
|
|
||||||
.dash-root .proj-row:not(.proj-head):hover { background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
|
/* The gallery is the one view that wants the whole viewport: it has its own sidebar, toolbar and
|
||||||
.dash-root .proj-lead { min-width: 0; }
|
scrollers, so any height we leave on the table is wasted chrome. The old big PageHead cost ~90px;
|
||||||
.dash-root .proj-lead-name { font-size: 13.5px; font-weight: 700; }
|
a slim header (~40px) + compact banner + tight gaps hand almost all of that back to the shell.
|
||||||
.dash-root .proj-lead-sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
96px = the dashboard's top padding + the slim header row; `.gal-shell` (flex:1; min-height:0)
|
||||||
.dash-root .proj-text { font-size: 13px; color: var(--text-2); text-align: center; }
|
consumes whatever is left after the header and the optional demo banner. */
|
||||||
.dash-root .proj-c-num { text-align: center; }
|
.dash-root .gal { display: flex; flex-direction: column; gap: 10px; height: calc(100vh - 96px); min-height: 700px; }
|
||||||
.dash-root .proj-row .ds-pill { justify-self: center; justify-content: center; }
|
|
||||||
.dash-root .proj-c-act { display: flex; justify-content: center; align-items: center; gap: 6px; position: relative; }
|
/* Slim inline header — replaces the tall PageHead. One row, ~40px, so the shell keeps the height. */
|
||||||
.dash-root .proj-cd-search { margin-bottom: 14px; }
|
.dash-root .gal-head { display: flex; align-items: center; gap: 10px; min-height: 36px; flex: 0 0 auto; }
|
||||||
.dash-root .proj-cd-table { margin-bottom: 14px; }
|
.dash-root .gal-head-ic { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); flex: 0 0 auto; }
|
||||||
.dash-root .proj-progress { display: flex; align-items: center; gap: 8px; }
|
.dash-root .gal-head-title { font-size: 16px; font-weight: 700; line-height: 1.1; margin: 0; }
|
||||||
.dash-root .proj-progress-track { flex: 1; display: block; height: 6px; border-radius: 99px; background: var(--track); overflow: hidden; }
|
.dash-root .gal-head-sub { color: var(--muted); font-size: 12.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.dash-root .proj-progress-fill { display: block; height: 100%; border-radius: 99px; background: var(--orange); transition: width 0.5s cubic-bezier(0.2,0.7,0.2,1); }
|
@media (max-width: 720px) { .dash-root .gal-head-sub { display: none; } }
|
||||||
.dash-root .proj-progress b { font-size: 12px; font-weight: 700; color: var(--text-2); flex: 0 0 auto; }
|
|
||||||
.dash-root .proj-health { font-weight: 800; text-align: center; }
|
/* `.view` sets `z-index: 1`, which makes it a stacking context and traps the SDK's
|
||||||
.dash-root .proj-empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 54px 20px; color: var(--muted); text-align: center; }
|
full-screen overlays (lightbox z1000, editors z1050, camera z1080, modals z1100)
|
||||||
.dash-root .proj-foot { display: flex; align-items: center; justify-content: space-between; padding: 16px 4px 0; font-size: 12.5px; color: var(--muted); }
|
underneath the topbar's `z-index: 20`. Opting this one view out of the stacking
|
||||||
.dash-root .proj-foot b { color: var(--text); font-size: 15px; font-weight: 800; }
|
context lets those overlays cover the whole dashboard, as they must. The view still
|
||||||
.dash-root .proj-detail-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
paints above the ambient `.dash-content::before` glow because it follows it in the DOM. */
|
||||||
.dash-root .proj-detail-row span { color: var(--muted); }
|
.dash-root .view.gal { z-index: auto; }
|
||||||
.dash-root .proj-detail-row b { color: var(--text); font-weight: 700; }
|
|
||||||
|
/* Compact single-line demo banner (~34px). Truncates rather than wrapping so it never steals a
|
||||||
|
second row of height from the shell. */
|
||||||
|
.dash-root .gal-banner { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 6px 12px; border-radius: 11px; border: 1px solid var(--border); background: color-mix(in srgb, var(--orange) 9%, var(--panel-2)); color: var(--text-2); font-size: 12px; font-weight: 500; flex: 0 0 auto; white-space: nowrap; overflow: hidden; }
|
||||||
|
.dash-root .gal-banner span { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dash-root .gal-banner svg { color: var(--orange); flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* The gallery's own viewport. `overflow: hidden` keeps the SDK's internal scrollers
|
||||||
|
in charge; its full-screen overlays (lightbox/editor/camera) are position:fixed
|
||||||
|
and deliberately escape this box to cover the whole dashboard. */
|
||||||
|
.dash-root .gal-shell { flex: 1; min-height: 0; position: relative; border-radius: 18px; border: 1px solid var(--border); background: var(--panel-2); overflow: hidden; box-shadow: var(--card-hi), 0 1px 2px rgba(0, 0, 0, 0.18); }
|
||||||
|
.dash-root[data-theme="dark"] .gal-shell { border: 0.5px solid #452b1a; border-radius: 20px; }
|
||||||
|
|
||||||
|
/* The SDK's embedded root fills this box. (--apg-overlay-top is set from the
|
||||||
|
component's `style` prop — the SDK writes an inline default that a stylesheet
|
||||||
|
rule could not override.) */
|
||||||
|
.dash-root .gal-shell .apg { height: 100%; }
|
||||||
|
|
||||||
|
/* ---- Fullscreen (the SDK puts `.apg--fullscreen` on its root: position:fixed; inset:0) ----
|
||||||
|
A position:fixed box is only clipped by an ancestor that is its CONTAINING BLOCK, which
|
||||||
|
`overflow`/`border-radius`/`box-shadow` alone never create — only transform / filter /
|
||||||
|
perspective / backdrop-filter / will-change / contain do. Nothing on the path
|
||||||
|
(.dash-content > .view.gal > .gal-shell) uses any of those: `.view`'s `ds-fade` animates
|
||||||
|
opacity only, and `.view.gal` already drops the `z-index: 1` stacking context. So the
|
||||||
|
fullscreen root does escape today — these rules make that survive an edit above. */
|
||||||
|
|
||||||
|
/* `.dash-root .gal-shell .apg` (0,3,0) would otherwise out-specify the SDK's own sizing; with
|
||||||
|
inset:0 driving the box, height must get out of the way. */
|
||||||
|
.dash-root .gal-shell .apg.apg--fullscreen {
|
||||||
|
height: auto;
|
||||||
|
/* Above the topbar (z-index: 20) and the sidebar, below the SDK's own overlays (1000+). */
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Belt and braces: if a future rule ever DOES make `.gal-shell` a containing block, an
|
||||||
|
`overflow: hidden` on it would crop the fullscreen root to the embedded box. Drop the clip
|
||||||
|
(and the rounded corner it exists to enforce) for exactly as long as fullscreen is on. */
|
||||||
|
.dash-root .gal-shell:has(.apg--fullscreen) { overflow: visible; }
|
||||||
|
|
||||||
|
.dash-root .gal-placeholder { height: 100%; min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; padding: 28px; }
|
||||||
|
.dash-root .gal-placeholder-ic { width: 66px; height: 66px; border-radius: 20px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); }
|
||||||
|
.dash-root .gal-placeholder p { color: var(--muted); font-size: 13px; max-width: 420px; }
|
||||||
|
.dash-root .gal-placeholder h3 { font-size: 16px; font-weight: 700; }
|
||||||
|
.dash-root .gal-placeholder-error .gal-placeholder-ic { background: color-mix(in srgb, var(--red) 88%, #000); box-shadow: 0 10px 28px -12px color-mix(in srgb, var(--red) 60%, transparent); }
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
/* Narrower chrome: a little less top offset, and a smaller floor so short viewports still work. */
|
||||||
|
.dash-root .gal { height: calc(100vh - 84px); min-height: 560px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Org Settings → Integrations ---- */
|
/* ---- Org Settings → Integrations ---- */
|
||||||
.dash-root .settings-section { margin-top: 8px; }
|
.dash-root .settings-section { margin-top: 8px; }
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { TeamManagement } from "./team-management";
|
|||||||
import { MessengerSdk } from "./messenger-sdk";
|
import { MessengerSdk } from "./messenger-sdk";
|
||||||
import { InboxSdk } from "./inbox-sdk";
|
import { InboxSdk } from "./inbox-sdk";
|
||||||
import { Settings } from "./settings";
|
import { Settings } from "./settings";
|
||||||
import { Projects } from "./projects";
|
import { SmartGallery } from "./smart-gallery";
|
||||||
import "../../app/dashboard/dashboard.css";
|
import "../../app/dashboard/dashboard.css";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -52,8 +52,8 @@ export function Dashboard() {
|
|||||||
: active === "messenger" ? <MessengerSdk />
|
: active === "messenger" ? <MessengerSdk />
|
||||||
: active === "inbox" ? <InboxSdk />
|
: active === "inbox" ? <InboxSdk />
|
||||||
: active === "settings" ? <Settings />
|
: active === "settings" ? <Settings />
|
||||||
|
: active === "gallery" ? <SmartGallery theme={theme} />
|
||||||
: active === "team" ? <TeamManagement />
|
: active === "team" ? <TeamManagement />
|
||||||
: active === "projects" ? <Projects />
|
|
||||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// LynkedUp Pro — Projects mock data (Construction jobs + Pipeline
|
|
||||||
// leads). Used only when the Shell isn't configured, so the demo
|
|
||||||
// stays fully interactive without a backend — mirrors team-data.ts.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export type ConstructionStatus = "active" | "complete" | "stuck" | "followup";
|
|
||||||
|
|
||||||
export type ConstructionLead = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
status: ConstructionStatus;
|
|
||||||
stage: string;
|
|
||||||
jobType: string;
|
|
||||||
agent: string;
|
|
||||||
progress: number; // 0-100
|
|
||||||
health: number; // 0-100
|
|
||||||
value: number; // dollars
|
|
||||||
};
|
|
||||||
|
|
||||||
// The first 8 rows mirror the reference design exactly (name, address, job
|
|
||||||
// type, stage, status, progress and health). The rest extend the set to 15
|
|
||||||
// construction jobs with a realistic status/stage spread so the quick
|
|
||||||
// filters (Active/Complete/Stuck/Follow-up) all have something to show.
|
|
||||||
export const constructionLeads: ConstructionLead[] = [
|
|
||||||
{ id: "c1", name: "Derek Holloway", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 28500 },
|
|
||||||
{ id: "c2", name: "Brenda Castillo", address: "5501 Shady Brook Ln, Plano TX 75093", status: "active", stage: "New Lead", jobType: "Roof Inspection", agent: "Cody Tatum", progress: 14, health: 65, value: 15400 },
|
|
||||||
{ id: "c3", name: "Antonio Reyes", address: "1122 Custer Rd, Plano TX 75075", status: "active", stage: "New Lead", jobType: "Gutter Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 19200 },
|
|
||||||
{ id: "c4", name: "Sylvia Nguyen", address: "3308 Roundrock Trl, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Siding Repair", agent: "Cody Tatum", progress: 14, health: 65, value: 21900 },
|
|
||||||
{ id: "c5", name: "Raymond Osei", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 31200 },
|
|
||||||
{ id: "c6", name: "Carolyn Estrada", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Window Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 22400 },
|
|
||||||
{ id: "c7", name: "Marcus Tillman", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 26800 },
|
|
||||||
{ id: "c8", name: "Diane Kowalski", address: "815 Independence Pkwy, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 29500 },
|
|
||||||
{ id: "c9", name: "Felicia Grant", address: "9021 Legacy Dr, Plano TX 75024", status: "complete", stage: "Completed", jobType: "Gutter Replacement", agent: "Emma Wilson", progress: 100, health: 92, value: 18600 },
|
|
||||||
{ id: "c10", name: "Harold Jennings", address: "4477 Coit Rd, Plano TX 75075", status: "stuck", stage: "Permit Hold", jobType: "Siding Repair", agent: "Liam Foster", progress: 42, health: 38, value: 24800 },
|
|
||||||
{ id: "c11", name: "Yolanda Brooks", address: "6650 Parker Rd, Plano TX 75093", status: "followup", stage: "Awaiting Customer", jobType: "Window Replacement", agent: "Sophie Turner", progress: 55, health: 51, value: 20700 },
|
|
||||||
{ id: "c12", name: "Preston Wallace", address: "3312 Alma Dr, Plano TX 75075", status: "active", stage: "Scheduled", jobType: "Roof Repair", agent: "Cody Tatum", progress: 30, health: 70, value: 27500 },
|
|
||||||
{ id: "c13", name: "Nadia Ferreira", address: "8890 Independence Pkwy, Plano TX 75025", status: "complete", stage: "Completed", jobType: "Roof Inspection", agent: "Emma Wilson", progress: 100, health: 95, value: 16200 },
|
|
||||||
{ id: "c14", name: "Louis Abernathy", address: "2200 K Ave, Plano TX 75074", status: "stuck", stage: "Material Delay", jobType: "Roof Replacement", agent: "Liam Foster", progress: 60, health: 44, value: 24900 },
|
|
||||||
{ id: "c15", name: "Grace Delgado", address: "7301 Ohio Dr, Plano TX 75093", status: "active", stage: "In Progress", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 68, health: 78, value: 51600 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type CollectionStatus = "paid" | "pending" | "overdue";
|
|
||||||
|
|
||||||
export type CollectionRecord = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
referenceId: string;
|
|
||||||
date: string; // YYYY-MM-DD
|
|
||||||
type: string; // "Deposit — 30%", "Progress Payment — 40%", "Final Payment — 30%"
|
|
||||||
status: CollectionStatus;
|
|
||||||
amount: number; // dollars
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deterministic 3-installment payment schedule per construction job (deposit /
|
|
||||||
// progress / final), so "Total Collected" has something real to show without a
|
|
||||||
// billing backend. Which installments are PAID vs PENDING/OVERDUE follows the
|
|
||||||
// job's own status — a completed job is fully paid, a stuck job has an overdue
|
|
||||||
// progress payment, etc.
|
|
||||||
const INSTALLMENTS = [
|
|
||||||
{ label: "Deposit — 30%", pct: 0.3 },
|
|
||||||
{ label: "Progress Payment — 40%", pct: 0.4 },
|
|
||||||
{ label: "Final Payment — 30%", pct: 0.3 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function statusFor(jobStatus: ConstructionStatus, i: number): CollectionStatus {
|
|
||||||
if (jobStatus === "complete") return "paid";
|
|
||||||
if (i === 0) return "paid"; // deposit is always collected up front
|
|
||||||
if (jobStatus === "stuck" && i === 1) return "overdue";
|
|
||||||
return "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectionsFor(lead: ConstructionLead, leadIndex: number): CollectionRecord[] {
|
|
||||||
const deposit = Math.round(lead.value * INSTALLMENTS[0].pct);
|
|
||||||
const progress = Math.round(lead.value * INSTALLMENTS[1].pct);
|
|
||||||
const final = lead.value - deposit - progress; // remainder avoids rounding drift
|
|
||||||
const amounts = [deposit, progress, final];
|
|
||||||
const ref = `PRJ-2026-${String(leadIndex + 1).padStart(3, "0")}`;
|
|
||||||
return INSTALLMENTS.map((inst, i) => {
|
|
||||||
const month = ((leadIndex * 2 + i * 2) % 12) + 1;
|
|
||||||
const day = ((leadIndex * 5 + i * 7) % 27) + 1;
|
|
||||||
return {
|
|
||||||
id: `${lead.id}-r${i + 1}`,
|
|
||||||
name: lead.name,
|
|
||||||
address: lead.address,
|
|
||||||
referenceId: `${ref}-R${i + 1}`,
|
|
||||||
date: `2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
|
|
||||||
type: inst.label,
|
|
||||||
status: statusFor(lead.status, i),
|
|
||||||
amount: amounts[i],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PipelineLead = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
jobType: string;
|
|
||||||
stage: string;
|
|
||||||
agent: string;
|
|
||||||
createdAgo: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FIRST_NAMES = [
|
|
||||||
"Wesley", "Ivy", "Corey", "Renata", "Miles", "Paula", "Jasper", "Dana",
|
|
||||||
"Terrence", "Alina", "Grant", "Bethany", "Owen", "Marisol", "Kurt",
|
|
||||||
"Tanya", "Reggie", "Selena", "Blake", "Vivian", "Colton", "Priya",
|
|
||||||
"Gerald", "Fiona",
|
|
||||||
];
|
|
||||||
const LAST_NAMES = [
|
|
||||||
"Whitfield", "Caldwell", "Rourke", "Sanborn", "Delacroix", "Winters",
|
|
||||||
"Blackwood", "Herrera", "Sweeney", "Okafor", "Lindgren", "Pruitt",
|
|
||||||
"Castellano", "Marsh", "Yaeger", "Doyle", "Kowalczyk", "Beaumont",
|
|
||||||
"Ashworth", "Nakamura", "Villanueva", "Prescott", "Hutchins",
|
|
||||||
"Loomis", "Stanhope", "Everly", "Boone",
|
|
||||||
];
|
|
||||||
const STREETS = [
|
|
||||||
"Independence Pkwy", "Legacy Dr", "Coit Rd", "Parker Rd", "Alma Dr",
|
|
||||||
"K Ave", "Ohio Dr", "Preston Rd", "Spring Creek Pkwy", "Custer Rd",
|
|
||||||
"Shady Brook Ln", "Roundrock Trl", "Ridgeview Dr", "Chisholm Trl",
|
|
||||||
"Los Rios Blvd",
|
|
||||||
];
|
|
||||||
const ZIPS = ["75023", "75024", "75025", "75074", "75075", "75093"];
|
|
||||||
const LEAD_JOB_TYPES = ["Roof Replacement", "Roof Inspection", "Gutter Replacement", "Siding Repair", "Window Replacement", "Roof Repair"];
|
|
||||||
const LEAD_STAGES = ["New Inquiry", "Contacted", "Qualifying", "Quote Requested", "Nurture"];
|
|
||||||
const LEAD_AGENTS = ["Cody Tatum", "Emma Wilson", "Liam Foster", "Sophie Turner", "Chloe Adams", "Unassigned"];
|
|
||||||
|
|
||||||
// Deterministic (no Math.random/Date.now) so server- and client-render match.
|
|
||||||
export const pipelineLeads: PipelineLead[] = Array.from({ length: 45 }, (_, i) => {
|
|
||||||
const first = FIRST_NAMES[i % FIRST_NAMES.length];
|
|
||||||
const last = LAST_NAMES[(i * 7) % LAST_NAMES.length];
|
|
||||||
const streetNum = 1000 + ((i * 137) % 8900);
|
|
||||||
const street = STREETS[(i * 3) % STREETS.length];
|
|
||||||
const zip = ZIPS[i % ZIPS.length];
|
|
||||||
const daysAgo = ((i * 3) % 21) + 1;
|
|
||||||
return {
|
|
||||||
id: `p${i + 1}`,
|
|
||||||
name: `${first} ${last}`,
|
|
||||||
address: `${streetNum} ${street}, Plano TX ${zip}`,
|
|
||||||
jobType: LEAD_JOB_TYPES[i % LEAD_JOB_TYPES.length],
|
|
||||||
stage: LEAD_STAGES[(i * 2) % LEAD_STAGES.length],
|
|
||||||
agent: LEAD_AGENTS[(i * 5) % LEAD_AGENTS.length],
|
|
||||||
createdAgo: daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Projects — Construction jobs + Pipeline leads.
|
|
||||||
// Data comes from useProjectsData(): the local mock when the
|
|
||||||
// Shell isn't configured, or the live be-crm data door
|
|
||||||
// (crm.project.*) when it is. be-crm's Project model is generic
|
|
||||||
// (name/status/value/owner/address/dates) — mock mode additionally
|
|
||||||
// carries stage, job type, progress and health, which the live
|
|
||||||
// table simply doesn't render (nothing backs them).
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Btn, Icon, Modal, PageHead, Pill } from "./ui";
|
|
||||||
import {
|
|
||||||
QUICK_FILTERS, STATUS_LABEL, STATUS_TONE, useProjectsData,
|
|
||||||
type UiCollectionRecord, type UiProject, type UiProjectStatus,
|
|
||||||
} from "@/lib/projects-api";
|
|
||||||
|
|
||||||
const money = (n: number) => `$${Math.round(n).toLocaleString()}`;
|
|
||||||
// The collections ledger shows cents (matches invoice-style amounts); the rest of the page rounds to whole dollars.
|
|
||||||
const moneyExact = (n: number) => `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
|
|
||||||
const COLLECTION_STATUS_LABEL: Record<UiCollectionRecord["status"], string> = { paid: "Paid", pending: "Pending", overdue: "Overdue" };
|
|
||||||
const COLLECTION_STATUS_TONE: Record<UiCollectionRecord["status"], string> = { paid: "green", pending: "orange", overdue: "red" };
|
|
||||||
|
|
||||||
export function Projects() {
|
|
||||||
const data = useProjectsData();
|
|
||||||
const { projects, live } = data;
|
|
||||||
|
|
||||||
const [tab, setTab] = useState<"construction" | "pipeline">("construction");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | UiProjectStatus>("all");
|
|
||||||
const [viewProject, setViewProject] = useState<UiProject | null>(null);
|
|
||||||
|
|
||||||
const construction = useMemo(() => projects.filter((p) => !p.isLead), [projects]);
|
|
||||||
const pipeline = useMemo(() => projects.filter((p) => p.isLead), [projects]);
|
|
||||||
const budgetTotal = useMemo(() => construction.reduce((s, p) => s + (p.value ?? 0), 0), [construction]);
|
|
||||||
|
|
||||||
const list = tab === "construction" ? construction : pipeline;
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return list.filter((p) => {
|
|
||||||
const matchesStatus = tab === "pipeline" || statusFilter === "all" || p.status === statusFilter;
|
|
||||||
const matchesQ = !q
|
|
||||||
|| p.name.toLowerCase().includes(q)
|
|
||||||
|| (p.address ?? "").toLowerCase().includes(q)
|
|
||||||
|| (p.jobType ?? "").toLowerCase().includes(q)
|
|
||||||
|| p.agent.toLowerCase().includes(q);
|
|
||||||
return matchesStatus && matchesQ;
|
|
||||||
});
|
|
||||||
}, [list, statusFilter, query, tab]);
|
|
||||||
|
|
||||||
// Mock construction (richest): Lead | Status | Stage | Job Type | Agent | Progress | Health | ⋯
|
|
||||||
// Mock pipeline: Lead | Job Type | Stage | Agent | Created | ⋯
|
|
||||||
// Live (either tab, leaner — only fields be-crm actually stores): Lead | Status | Owner | Value | Due date | ⋯
|
|
||||||
const template = live
|
|
||||||
? "minmax(220px,2.3fr) 110px 160px 120px 130px 48px"
|
|
||||||
: tab === "construction"
|
|
||||||
? "minmax(220px,2.2fr) 104px 130px 150px 130px 130px 76px 48px"
|
|
||||||
: "minmax(220px,2.2fr) 150px 140px 130px 120px 48px";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="view proj">
|
|
||||||
<PageHead
|
|
||||||
title="Projects"
|
|
||||||
subtitle={`${construction.length} construction · ${pipeline.length} pipeline leads · ${money(budgetTotal)} budget`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{data.error && <div className="card proj-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn't load projects: {data.error}</p></div>}
|
|
||||||
|
|
||||||
<div className="tm-toolbar proj-toolbar">
|
|
||||||
<div className="tm-tabs" role="tablist">
|
|
||||||
<button role="tab" aria-selected={tab === "construction"} className={`tm-tab ${tab === "construction" ? "active" : ""}`} onClick={() => setTab("construction")}>
|
|
||||||
<Icon name="owners" size={16} /> <span>Construction</span>
|
|
||||||
<span className="tm-tab-badge">{construction.length}</span>
|
|
||||||
</button>
|
|
||||||
<button role="tab" aria-selected={tab === "pipeline"} className={`tm-tab ${tab === "pipeline" ? "active" : ""}`} onClick={() => setTab("pipeline")}>
|
|
||||||
<Icon name="pipeline" size={16} /> <span>Pipeline Leads</span>
|
|
||||||
<span className="tm-tab-badge">{pipeline.length}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tm-search">
|
|
||||||
<Icon name="search" size={16} />
|
|
||||||
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
||||||
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "construction" && (
|
|
||||||
<div className="tm-chips">
|
|
||||||
<button className={`tm-chip ${statusFilter === "all" ? "on" : ""}`} onClick={() => setStatusFilter("all")}>
|
|
||||||
All <i>{construction.length}</i>
|
|
||||||
</button>
|
|
||||||
{QUICK_FILTERS.map((s) => (
|
|
||||||
<button key={s} className={`tm-chip ${statusFilter === s ? "on" : ""}`} style={{ ["--rc" as string]: `var(--${STATUS_TONE[s] === "muted" ? "faint" : STATUS_TONE[s]})` }} onClick={() => setStatusFilter(s)}>
|
|
||||||
{STATUS_LABEL[s]} <i>{construction.filter((p) => p.status === s).length}</i>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.live && data.loading && projects.length === 0 ? (
|
|
||||||
<div className="card proj-empty"><span className="tm-empty-ic"><Icon name="owners" size={24} /></span><p>Loading projects…</p></div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="card proj-empty">
|
|
||||||
<span className="tm-empty-ic"><Icon name="search" size={24} /></span>
|
|
||||||
<p>No {tab === "construction" ? "projects" : "leads"} match your search.</p>
|
|
||||||
<Btn variant="soft" size="sm" onClick={() => { setQuery(""); setStatusFilter("all"); }}>Clear filters</Btn>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="card card-pad-0 proj-table">
|
|
||||||
<div className="proj-row proj-head" style={{ gridTemplateColumns: template }}>
|
|
||||||
<span>Lead</span>
|
|
||||||
{live ? (
|
|
||||||
<><span>Status</span><span>Owner</span><span className="proj-c-num">Value</span><span>Due date</span></>
|
|
||||||
) : tab === "construction" ? (
|
|
||||||
<><span>Status</span><span>Stage</span><span>Job type</span><span>Agent</span><span>Progress</span><span className="proj-c-num">Health</span></>
|
|
||||||
) : (
|
|
||||||
<><span>Job type</span><span>Stage</span><span>Agent</span><span>Created</span></>
|
|
||||||
)}
|
|
||||||
<span className="proj-c-act" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filtered.map((p) => (
|
|
||||||
<div className="proj-row" key={p.id} style={{ gridTemplateColumns: template }}>
|
|
||||||
<div className="proj-lead">
|
|
||||||
<div className="proj-lead-name">{p.name}</div>
|
|
||||||
{p.address && <div className="proj-lead-sub">{p.address}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{live ? (
|
|
||||||
<>
|
|
||||||
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
|
|
||||||
<span className="proj-text">{p.agent}</span>
|
|
||||||
<span className="proj-c-num">{p.value != null ? money(p.value) : <span className="tm-dash">—</span>}</span>
|
|
||||||
<span className="proj-text">{p.dueDate ? new Date(p.dueDate).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : <span className="tm-dash">—</span>}</span>
|
|
||||||
</>
|
|
||||||
) : tab === "construction" ? (
|
|
||||||
<>
|
|
||||||
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
|
|
||||||
<Pill tone="blue">{p.stage}</Pill>
|
|
||||||
<span className="proj-text">{p.jobType}</span>
|
|
||||||
<span className="proj-text">{p.agent}</span>
|
|
||||||
<div className="proj-progress" title={`${p.progress}%`}>
|
|
||||||
<span className="proj-progress-track"><span className="proj-progress-fill" style={{ width: `${p.progress ?? 0}%` }} /></span>
|
|
||||||
<b>{p.progress}%</b>
|
|
||||||
</div>
|
|
||||||
<span className="proj-c-num proj-health">{p.health}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="proj-text">{p.jobType}</span>
|
|
||||||
<Pill tone="blue">{p.stage}</Pill>
|
|
||||||
<span className="proj-text">{p.agent}</span>
|
|
||||||
<span className="proj-text">{p.createdAgo}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="proj-c-act">
|
|
||||||
<button className="ds-iconbtn" aria-label={`View collections for ${p.name}`} onClick={() => setViewProject(p)}><Icon name="eye" size={17} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="proj-foot">
|
|
||||||
<span>Showing {filtered.length} of {list.length} {tab === "construction" ? "projects" : "leads"}</span>
|
|
||||||
{tab === "construction" && <b>{money(budgetTotal)} total</b>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollectedDetailsModal project={viewProject} onClose={() => setViewProject(null)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------- */
|
|
||||||
/* Collected details modal — payment ledger for one project */
|
|
||||||
/* ---------------------------------------------------------- */
|
|
||||||
|
|
||||||
function CollectedDetailsModal({ project, onClose }: { project: UiProject | null; onClose: () => void }) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const records = project?.collections ?? [];
|
|
||||||
const total = useMemo(() => records.reduce((s, r) => s + r.amount, 0), [records]);
|
|
||||||
if (!project) return null;
|
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
const filtered = records.filter((r) =>
|
|
||||||
!q || r.name.toLowerCase().includes(q) || r.referenceId.toLowerCase().includes(q) || r.type.toLowerCase().includes(q));
|
|
||||||
const netTotal = filtered.reduce((s, r) => s + r.amount, 0);
|
|
||||||
const projectName = project.name;
|
|
||||||
|
|
||||||
function downloadCsv() {
|
|
||||||
const header = ["Name/Project", "Reference ID", "Date", "Type", "Status", "Amount"];
|
|
||||||
const rows = filtered.map((r) => [r.name, r.referenceId, r.date, r.type, COLLECTION_STATUS_LABEL[r.status], r.amount.toFixed(2)]);
|
|
||||||
const csv = [header, ...rows].map((row) => row.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
|
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${projectName.replace(/\s+/g, "-").toLowerCase()}-collections.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={!!project}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Total Collected Details"
|
|
||||||
subtitle={<>Total: <b>{moneyExact(total)}</b></>}
|
|
||||||
size="xl"
|
|
||||||
headerExtra={records.length > 0 && (
|
|
||||||
<button className="ds-iconbtn" aria-label="Download CSV" onClick={downloadCsv}><Icon name="download" size={18} /></button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{project.collections === null ? (
|
|
||||||
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>Collections aren't available yet for live projects — this needs a billing module in be-crm.</p></div>
|
|
||||||
) : records.length === 0 ? (
|
|
||||||
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>No payments collected yet for {project.name}.</p></div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="tm-search proj-cd-search">
|
|
||||||
<Icon name="search" size={16} />
|
|
||||||
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
||||||
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card card-pad-0 proj-table proj-cd-table">
|
|
||||||
<div className="proj-row proj-head" style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
|
|
||||||
<span>Name / Project</span><span>Reference ID</span><span>Date</span><span>Type</span><span>Status</span><span className="proj-c-num">Amount</span>
|
|
||||||
</div>
|
|
||||||
{filtered.map((r) => (
|
|
||||||
<div className="proj-row" key={r.id} style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
|
|
||||||
<div className="proj-lead">
|
|
||||||
<div className="proj-lead-name">{r.name}</div>
|
|
||||||
<div className="proj-lead-sub">{r.address}</div>
|
|
||||||
</div>
|
|
||||||
<span className="proj-text">{r.referenceId}</span>
|
|
||||||
<span className="proj-text">{r.date}</span>
|
|
||||||
<span className="proj-text">{r.type}</span>
|
|
||||||
<Pill tone={COLLECTION_STATUS_TONE[r.status]}>{COLLECTION_STATUS_LABEL[r.status].toUpperCase()}</Pill>
|
|
||||||
<span className="proj-c-num">{moneyExact(r.amount)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="proj-foot">
|
|
||||||
<span>Showing {filtered.length} record{filtered.length === 1 ? "" : "s"}</span>
|
|
||||||
<b>Net Total: {moneyExact(netTotal)}</b>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" },
|
{ key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" },
|
||||||
{ key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, messages, alerts and mail — all in one" },
|
{ key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, messages, alerts and mail — all in one" },
|
||||||
|
{ key: "gallery", label: "Smart Gallery", icon: "gallery", subtitle: "Every photo and video for your jobs — searchable, editable and shareable" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,14 +80,21 @@ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
|||||||
// brand-new user with no membership); every other item requires membership, and the
|
// brand-new user with no membership); every other item requires membership, and the
|
||||||
// items mapped here additionally require the given permission. Unmapped items are
|
// items mapped here additionally require the given permission. Unmapped items are
|
||||||
// shown to any member. This is UX only — be-crm still enforces every action.
|
// shown to any member. This is UX only — be-crm still enforces every action.
|
||||||
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox"]);
|
//
|
||||||
|
// `gallery` DECISION: it stays in ALWAYS_VISIBLE (nav row always shown to members) rather than
|
||||||
|
// being gated here by `media.view`. The real access gate lives INSIDE the view (SmartGallery reads
|
||||||
|
// `useGalleryFeatures().canView`), which uses the permissive "member with zero media.* perms → all
|
||||||
|
// enabled" fallback. Gating the nav row here would use the stricter sidebar rule (member without the
|
||||||
|
// perm → hidden) and so would hide the gallery from freshly-seeded members before an admin has
|
||||||
|
// configured any Media perms — regressing the demo and the common member. So: always-visible row,
|
||||||
|
// real gating in-view. (be-crm enforces data access regardless of what the nav shows.)
|
||||||
|
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox", "gallery"]);
|
||||||
const NAV_PERMISSION: Record<string, string | undefined> = {
|
const NAV_PERMISSION: Record<string, string | undefined> = {
|
||||||
team: "team.manage",
|
team: "team.manage",
|
||||||
people: "team.manage",
|
people: "team.manage",
|
||||||
leads: "leads.manage",
|
leads: "leads.manage",
|
||||||
verify: "leads.manage",
|
verify: "leads.manage",
|
||||||
pipeline: "pipeline.manage",
|
pipeline: "pipeline.manage",
|
||||||
projects: "pipeline.manage",
|
|
||||||
estimates: "estimates.create",
|
estimates: "estimates.create",
|
||||||
procanvas: "estimates.create",
|
procanvas: "estimates.create",
|
||||||
dispatch: "dispatch.manage",
|
dispatch: "dispatch.manage",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// The actual @photo-gallery/sdk mount. Split out of smart-gallery.tsx so it can be
|
||||||
|
// loaded with next/dynamic({ ssr: false }) — the SDK is browser-only (matchMedia,
|
||||||
|
// IndexedDB, Leaflet, canvas) and pulls in heavy optional ML models on demand, so it
|
||||||
|
// must stay out of the dashboard's initial bundle and out of the server render.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { PhotoGallery, type ViewId } from "@photo-gallery/sdk";
|
||||||
|
import { createCrmAIProvider } from "@/lib/gallery-ai";
|
||||||
|
import {
|
||||||
|
GALLERY_THEME_TOKENS,
|
||||||
|
useGalleryFeatures,
|
||||||
|
useGalleryLockProvider,
|
||||||
|
useGalleryStorage,
|
||||||
|
useGalleryUser,
|
||||||
|
} from "@/lib/gallery-api";
|
||||||
|
|
||||||
|
import "@photo-gallery/sdk/styles.css";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
export interface SmartGalleryMountProps {
|
||||||
|
/** The dashboard's current appearance — the gallery must never diverge from the host. */
|
||||||
|
theme: "dark" | "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmartGalleryMount({ theme }: SmartGalleryMountProps) {
|
||||||
|
const { adapter } = useGalleryStorage();
|
||||||
|
const currentUser = useGalleryUser();
|
||||||
|
// Server-backed Recently Deleted lock when the Shell is wired; `undefined` in demo mode, which
|
||||||
|
// leaves the SDK on its own device-local lock (see useGalleryLockProvider).
|
||||||
|
const lockProvider = useGalleryLockProvider();
|
||||||
|
// Feature toggles resolved from the caller's CRM permissions (Media group). Superadmins/owners and
|
||||||
|
// the demo see everything; see useGalleryFeatures for the safe permissive fallback.
|
||||||
|
const { features } = useGalleryFeatures();
|
||||||
|
|
||||||
|
// Sidebar rows + Collections sections the CRM never wants to surface. The SDK hides both the row and
|
||||||
|
// the matching Collections section for each id. Screenshots + Documents aren't part of a roofing CRM.
|
||||||
|
const hiddenViews: ViewId[] = ["screenshots", "sys:documents"];
|
||||||
|
|
||||||
|
// One provider per mount. Every model is dynamically imported inside it, so constructing
|
||||||
|
// it is cheap; the weight only arrives when a photo is actually analyzed.
|
||||||
|
const ai = useMemo(() => createCrmAIProvider(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhotoGallery
|
||||||
|
embedded
|
||||||
|
adapter={adapter}
|
||||||
|
ai={ai}
|
||||||
|
// The CRM owns light/dark, so the in-gallery Appearance switcher is suppressed and the
|
||||||
|
// theme is driven straight off the dashboard's own toggle.
|
||||||
|
theme={theme}
|
||||||
|
chrome={{ titlebar: false, themeSwitcher: false }}
|
||||||
|
themeTokens={GALLERY_THEME_TOKENS}
|
||||||
|
borderRadius={12}
|
||||||
|
currentUser={currentUser}
|
||||||
|
lockProvider={lockProvider}
|
||||||
|
title="Smart Gallery"
|
||||||
|
// The SDK's floating Info panel defaults to 64px from the top — the height of its
|
||||||
|
// OWN toolbar. Inside the dashboard it has to clear the 84px CRM topbar instead.
|
||||||
|
// `style` is applied after the token vars, so this wins over the SDK's inline default.
|
||||||
|
style={{ ["--apg-overlay-top" as string]: "96px" }}
|
||||||
|
hiddenViews={hiddenViews}
|
||||||
|
features={features}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Smart Gallery — the tenant's media library, powered by @photo-gallery/sdk embedded
|
||||||
|
// inside the dashboard shell. The SDK owns the gallery experience (grid, lightbox,
|
||||||
|
// photo + video editors, map, people, versions, comments, AI); this file owns the
|
||||||
|
// LynkedUp chrome around it: page head, demo-mode banner, sizing, and failure
|
||||||
|
// containment so a gallery fault can never take the dashboard down.
|
||||||
|
//
|
||||||
|
// Storage + identity come from `@/lib/gallery-api` (be-crm data door when the Shell is
|
||||||
|
// configured, device-local otherwise). Theming comes from GALLERY_THEME_TOKENS, which
|
||||||
|
// maps the SDK's tokens onto this dashboard's own CSS variables.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import { useGalleryFeatures, useGalleryStorage } from "@/lib/gallery-api";
|
||||||
|
|
||||||
|
const SmartGalleryMount = dynamic(() => import("./smart-gallery-mount"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <GalleryPlaceholder label="Loading your library…" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SmartGallery({ theme }: { theme: "dark" | "light" }) {
|
||||||
|
const { live } = useGalleryStorage();
|
||||||
|
// Whole-view gate. `media.view` with the same permissive fallback the feature toggles use, so a
|
||||||
|
// superadmin/owner and the demo always pass, and a member is only blocked if they hold some Media
|
||||||
|
// perms but not `media.view`. The sidebar keeps the row visible (see sidebar.tsx §4) — the real
|
||||||
|
// gate is here.
|
||||||
|
const { canView } = useGalleryFeatures();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="view gal">
|
||||||
|
{/* Slim header — the big PageHead ate vertical space the gallery needs. The CRM topbar already
|
||||||
|
shows the "Smart Gallery" title; this compact row (~40px) just adds context and the icon. */}
|
||||||
|
<div className="gal-head">
|
||||||
|
<span className="gal-head-ic">
|
||||||
|
<Icon name="gallery" size={16} />
|
||||||
|
</span>
|
||||||
|
<h1 className="gal-head-title">Smart Gallery</h1>
|
||||||
|
<span className="gal-head-sub">Every photo and video for your jobs — searchable, editable and shareable.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!live && (
|
||||||
|
<div className="gal-banner">
|
||||||
|
<Icon name="info" size={14} />
|
||||||
|
<span>Demo mode — stored on this device only. It goes live once the Shell + be-crm are connected.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canView ? (
|
||||||
|
<div className="gal-shell">
|
||||||
|
<GalleryBoundary>
|
||||||
|
<SmartGalleryMount theme={theme} />
|
||||||
|
</GalleryBoundary>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="gal-shell">
|
||||||
|
<div className="gal-placeholder">
|
||||||
|
<span className="gal-placeholder-ic">
|
||||||
|
<Icon name="lock" size={28} />
|
||||||
|
</span>
|
||||||
|
<h3>You don't have access to the gallery</h3>
|
||||||
|
<p>Ask a workspace admin to grant you the “View Smart Gallery” permission.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function GalleryPlaceholder({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="gal-placeholder">
|
||||||
|
<span className="gal-placeholder-ic">
|
||||||
|
<Icon name="gallery" size={30} />
|
||||||
|
</span>
|
||||||
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gallery is a large third-party surface with canvas, workers and optional ML models. A render
|
||||||
|
* fault inside it must degrade to a message rather than blanking the whole dashboard, so it gets its
|
||||||
|
* own error boundary. (Error boundaries still require a class component in React 19.)
|
||||||
|
*/
|
||||||
|
class GalleryBoundary extends Component<{ children: ReactNode }, BoundaryState> {
|
||||||
|
state: BoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): BoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
console.error("[smart-gallery] render failed", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
return (
|
||||||
|
<div className="gal-placeholder gal-placeholder-error">
|
||||||
|
<span className="gal-placeholder-ic">
|
||||||
|
<Icon name="alert" size={30} />
|
||||||
|
</span>
|
||||||
|
<h3>The gallery could not be displayed</h3>
|
||||||
|
<p>{error.message || "An unexpected error occurred."}</p>
|
||||||
|
<button className="ds-btn v-outline s-sm" onClick={() => this.setState({ error: null })}>
|
||||||
|
<Icon name="refresh" size={14} /> Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
|
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
|
||||||
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
|
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
|
||||||
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
|
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
|
||||||
UsersRound, Download, type LucideIcon,
|
UsersRound, Image as ImageIcon, Images, File, FolderOpen, Download,
|
||||||
|
Video, Play, LayoutGrid, ZoomIn, type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
@@ -49,7 +50,11 @@ const ICONS: Record<string, LucideIcon> = {
|
|||||||
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
|
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
|
||||||
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
|
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
|
||||||
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
|
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
|
||||||
team: UsersRound, dots: MoreHorizontal, download: Download,
|
team: UsersRound, dots: MoreHorizontal,
|
||||||
|
// media / gallery (also used by messenger.tsx, which already asks for image/file)
|
||||||
|
image: ImageIcon, gallery: Images, file: File, folder: FolderOpen,
|
||||||
|
download: Download, video: Video, play: Play, grid: LayoutGrid,
|
||||||
|
filter: Filter, zoom: ZoomIn, sparkle: Sparkles, "map-pin": MapPin,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
|
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
|
||||||
@@ -217,9 +222,9 @@ export function OtpField({ length = 6, value, onChange, autoFocus = true }: { le
|
|||||||
/* Modal */
|
/* Modal */
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: {
|
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: {
|
||||||
open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
|
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string;
|
||||||
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
|
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
|
||||||
}) {
|
}) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
|
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
|
||||||
@@ -246,11 +251,8 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
|
|||||||
{subtitle && <p>{subtitle}</p>}
|
{subtitle && <p>{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-modal-head-r">
|
|
||||||
{headerExtra}
|
|
||||||
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="ds-modal-body">{children}</div>
|
<div className="ds-modal-body">{children}</div>
|
||||||
{footer && <div className="ds-modal-foot">{footer}</div>}
|
{footer && <div className="ds-modal-foot">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
|
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Attachment,
|
||||||
ChannelSummary,
|
ChannelSummary,
|
||||||
ChannelVisibility,
|
ChannelVisibility,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -32,7 +33,7 @@ interface ConversationDTO {
|
|||||||
threadId: string; subject: string | null; membership: Membership | null;
|
threadId: string; subject: string | null; membership: Membership | null;
|
||||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||||
}
|
}
|
||||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
|
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null; attachment?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null }
|
||||||
|
|
||||||
const POLL_MS = 4000;
|
const POLL_MS = 4000;
|
||||||
const REACTION = "reaction";
|
const REACTION = "reaction";
|
||||||
@@ -58,7 +59,7 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
|||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on("message", (m) => {
|
socket.on("message", (m) => {
|
||||||
this.ingestReactions(m);
|
this.ingestReactions(m);
|
||||||
this.emit(m.threadId, { kind: "message", message: this.fromKernel(m) });
|
void this.toKernelMessage(m).then((message) => this.emit(m.threadId, { kind: "message", message }));
|
||||||
});
|
});
|
||||||
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
|
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
|
||||||
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
|
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
|
||||||
@@ -99,28 +100,43 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
|||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
const res = await this.socket.openThread(threadId); // joins so live events flow
|
const res = await this.socket.openThread(threadId); // joins so live events flow
|
||||||
this.joined.add(threadId);
|
this.joined.add(threadId);
|
||||||
return res.history.map((m) => {
|
return Promise.all(
|
||||||
|
res.history.map((m) => {
|
||||||
this.ingestReactions(m);
|
this.ingestReactions(m);
|
||||||
return this.fromKernel(m);
|
return this.toKernelMessage(m);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||||
return msgs.map((m) => this.fromDto(m));
|
return Promise.all(msgs.map((m) => this.toDtoMessage(m)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||||
|
const att = opts?.attachment;
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
const sendOpts = {
|
const sendOpts = {
|
||||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||||
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
||||||
|
...(att?.contentRef ? { attachment: { contentRef: att.contentRef, mimeType: att.mime, sizeBytes: att.sizeBytes ?? 0 } } : {}),
|
||||||
};
|
};
|
||||||
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||||
return this.fromKernel(m);
|
const msg = this.fromKernel(m);
|
||||||
|
// Reuse the staged attachment (already carries a display URL from upload) for instant render.
|
||||||
|
return att ? { ...msg, attachment: att } : msg;
|
||||||
}
|
}
|
||||||
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
|
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
|
||||||
const msg = this.fromDto(m);
|
const msg = this.fromDto(m);
|
||||||
this.polls.get(threadId)?.seen.add(msg.id);
|
this.polls.get(threadId)?.seen.add(msg.id);
|
||||||
return msg;
|
return att ? { ...msg, attachment: att } : msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file: File): Promise<Attachment> {
|
||||||
|
const mime = file.type || "application/octet-stream";
|
||||||
|
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
|
||||||
|
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||||
|
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||||
|
const url = await this.downloadUrl(objectKey, mime);
|
||||||
|
return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||||
@@ -191,9 +207,21 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
|||||||
await this.sdk.command("crm.messenger.channel.leave", { threadId });
|
await this.sdk.command("crm.messenger.channel.leave", { threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addMember(threadId: string, userId: string): Promise<void> {
|
||||||
|
await this.sdk.command("crm.messenger.participant.add", { threadId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(threadId: string, userId: string): Promise<void> {
|
||||||
|
await this.sdk.command("crm.messenger.participant.remove", { threadId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameConversation(threadId: string, subject: string): Promise<void> {
|
||||||
|
await this.sdk.command("crm.messenger.group.rename", { threadId, subject });
|
||||||
|
}
|
||||||
|
|
||||||
async listMembers(threadId: string): Promise<Person[]> {
|
async listMembers(threadId: string): Promise<Person[]> {
|
||||||
const rows = await this.sdk.query<Array<{ userId: string; displayName: string; role: string }>>("crm.messenger.members", { threadId });
|
const rows = await this.sdk.query<Array<{ userId: string; displayName: string; role: string }>>("crm.messenger.members", { threadId });
|
||||||
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: "staff" as const }));
|
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: r.role === "CUSTOMER" ? "customer" : "staff" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── polling fallback (no socket) ───────────────────────────────
|
// ── polling fallback (no socket) ───────────────────────────────
|
||||||
@@ -265,6 +293,31 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
|||||||
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
|
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── attachments ────────────────────────────────────────────────
|
||||||
|
/** A short-lived signed URL to display/download a stored object. */
|
||||||
|
private async downloadUrl(contentRef: string, mime?: string): Promise<string> {
|
||||||
|
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) });
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAttachment(a: { contentRef: string; mimeType: string; sizeBytes: number; filename?: string | null } | null | undefined): Promise<Attachment | undefined> {
|
||||||
|
if (!a?.contentRef) return undefined;
|
||||||
|
const url = await this.downloadUrl(a.contentRef, a.mimeType);
|
||||||
|
return { url, mime: a.mimeType, name: a.filename ?? "attachment", contentRef: a.contentRef, sizeBytes: a.sizeBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toKernelMessage(m: KernelMessage): Promise<Message> {
|
||||||
|
const base = this.fromKernel(m);
|
||||||
|
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||||
|
return att ? { ...base, attachment: att } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toDtoMessage(m: MessageDTO): Promise<Message> {
|
||||||
|
const base = this.fromDto(m);
|
||||||
|
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||||
|
return att ? { ...base, attachment: att } : base;
|
||||||
|
}
|
||||||
|
|
||||||
// ── reaction state ─────────────────────────────────────────────
|
// ── reaction state ─────────────────────────────────────────────
|
||||||
private ingestReactions(m: KernelMessage): void {
|
private ingestReactions(m: KernelMessage): void {
|
||||||
for (const a of m.annotations ?? []) {
|
for (const a of m.annotations ?? []) {
|
||||||
|
|||||||
@@ -0,0 +1,734 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CRM's AIProvider for the Smart Gallery.
|
||||||
|
*
|
||||||
|
* PROVENANCE: a port of the SDK demo's `createDemoAIProvider()` plus its
|
||||||
|
* clip/face/ocr/tensorflow/runpod-yolo providers and `imageEncode.ts` helpers
|
||||||
|
* (advance-photo-gallery-web-sdk/apps/web/src/lib/ai/*), collapsed into one
|
||||||
|
* module and retargeted from `/api/ai/*` to `/api/gallery/ai/*`.
|
||||||
|
*
|
||||||
|
* Capability split:
|
||||||
|
* - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key), or the
|
||||||
|
* RunPod YOLO classifier via /api/gallery/ai/classify when
|
||||||
|
* NEXT_PUBLIC_APG_RUNPOD_DETECT=true (COCO-SSD is the automatic fallback)
|
||||||
|
* - face detection + recognition: face-api.js in-browser → clustered into People
|
||||||
|
* - OCR: tesseract.js in-browser → searchable text + the Documents album
|
||||||
|
* - semantic search: CLIP via transformers.js in-browser
|
||||||
|
* - background removal: @imgly in-browser WASM, or the RunPod U²-Net endpoint
|
||||||
|
* when NEXT_PUBLIC_APG_RUNPOD_BG=true (in-browser is the fallback)
|
||||||
|
* - other generative edits / transcription / denoise / tilt: proxied through the
|
||||||
|
* server routes so the RunPod key never reaches the browser
|
||||||
|
*
|
||||||
|
* EVERY heavy model is behind `await import(...)` so none of it lands in the
|
||||||
|
* initial bundle, and every capability degrades to []/''/null with a
|
||||||
|
* console.warn rather than throwing — a failed model must never break the
|
||||||
|
* gallery UI.
|
||||||
|
*
|
||||||
|
* The in-browser models fetch weights from public CDNs (jsdelivr, huggingface,
|
||||||
|
* storage.googleapis.com, staticimgly.com). See docs/SMART_GALLERY.md for the
|
||||||
|
* list that would need CSP allow-listing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AIProvider, GenerativeEditOp, MediaItem } from "@photo-gallery/sdk";
|
||||||
|
|
||||||
|
// Derived from the provider interface so we import only the three public types.
|
||||||
|
type DetectedObject = Awaited<ReturnType<NonNullable<AIProvider["detectObjects"]>>>[number];
|
||||||
|
type DetectedFace = Awaited<ReturnType<NonNullable<AIProvider["detectFaces"]>>>[number];
|
||||||
|
type ImageSource = ImageBitmap | HTMLImageElement;
|
||||||
|
|
||||||
|
const API = "/api/gallery/ai";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// imageEncode helpers (ported from apps/web/src/lib/ai/imageEncode.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EncodedImage {
|
||||||
|
/** base64 JPEG (no data: prefix). */
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
/** Actual pixel dims of the encoded image (after downscale). */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw an image to a downscaled canvas and return base64 JPEG + its dims. */
|
||||||
|
export function imageToBase64(image: ImageSource, maxDim: number): EncodedImage {
|
||||||
|
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", width: cw, height: ch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterize a mask (ImageData, white = region to regenerate) to a PNG base64
|
||||||
|
* scaled to targetW×targetH so it matches the encoded image exactly — SD 3.5
|
||||||
|
* requires image and mask to be identical pixel sizes.
|
||||||
|
*/
|
||||||
|
export function maskToBase64(mask: ImageData, targetW: number, targetH: number): string {
|
||||||
|
const tmp = document.createElement("canvas");
|
||||||
|
tmp.width = mask.width;
|
||||||
|
tmp.height = mask.height;
|
||||||
|
const tctx = tmp.getContext("2d");
|
||||||
|
if (!tctx) throw new Error("Canvas not supported.");
|
||||||
|
tctx.putImageData(mask, 0, 0);
|
||||||
|
|
||||||
|
const out = document.createElement("canvas");
|
||||||
|
out.width = targetW;
|
||||||
|
out.height = targetH;
|
||||||
|
const octx = out.getContext("2d");
|
||||||
|
if (!octx) throw new Error("Canvas not supported.");
|
||||||
|
// Nearest-neighbour, not bilinear — keep the mask strictly binary so SD gets
|
||||||
|
// crisp white(regenerate)/black(keep) edges instead of an anti-aliased grey halo.
|
||||||
|
octx.imageSmoothingEnabled = false;
|
||||||
|
octx.drawImage(tmp, 0, 0, targetW, targetH);
|
||||||
|
return out.toDataURL("image/png").split(",")[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad an image with a neutral border for outpaint and return {imageBase64, maskBase64}
|
||||||
|
* as base64 PNG — the border is WHITE in the mask (regenerate), the original image
|
||||||
|
* area BLACK (keep). Capped at 1280px on the long side.
|
||||||
|
*/
|
||||||
|
export function padForOutpaint(
|
||||||
|
image: ImageSource,
|
||||||
|
factor: number,
|
||||||
|
): { imageBase64: string; maskBase64: string } {
|
||||||
|
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||||
|
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||||
|
const f = Math.max(1.1, Math.min(2, factor));
|
||||||
|
const maxDim = 1280;
|
||||||
|
let pw = Math.round(w * f);
|
||||||
|
let ph = Math.round(h * f);
|
||||||
|
const scale = Math.min(1, maxDim / Math.max(pw, ph));
|
||||||
|
pw = Math.max(16, Math.round(pw * scale));
|
||||||
|
ph = Math.max(16, Math.round(ph * scale));
|
||||||
|
const iw = Math.max(1, Math.round(w * scale));
|
||||||
|
const ih = Math.max(1, Math.round(h * scale));
|
||||||
|
const ox = Math.floor((pw - iw) / 2);
|
||||||
|
const oy = Math.floor((ph - ih) / 2);
|
||||||
|
|
||||||
|
const imgCanvas = document.createElement("canvas");
|
||||||
|
imgCanvas.width = pw;
|
||||||
|
imgCanvas.height = ph;
|
||||||
|
const ictx = imgCanvas.getContext("2d");
|
||||||
|
if (!ictx) throw new Error("Canvas not supported.");
|
||||||
|
// Fill the new border with a blurred, stretched copy of the photo so the model
|
||||||
|
// has real color/context to continue from — flat gray gives it nothing.
|
||||||
|
ictx.filter = "blur(28px)";
|
||||||
|
ictx.drawImage(image as CanvasImageSource, 0, 0, pw, ph);
|
||||||
|
ictx.filter = "none";
|
||||||
|
ictx.drawImage(image as CanvasImageSource, ox, oy, iw, ih);
|
||||||
|
|
||||||
|
const maskCanvas = document.createElement("canvas");
|
||||||
|
maskCanvas.width = pw;
|
||||||
|
maskCanvas.height = ph;
|
||||||
|
const mctx = maskCanvas.getContext("2d");
|
||||||
|
if (!mctx) throw new Error("Canvas not supported.");
|
||||||
|
mctx.fillStyle = "#ffffff";
|
||||||
|
mctx.fillRect(0, 0, pw, ph);
|
||||||
|
mctx.fillStyle = "#000000";
|
||||||
|
mctx.fillRect(ox, oy, iw, ih);
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageBase64: imgCanvas.toDataURL("image/png").split(",")[1] ?? "",
|
||||||
|
maskBase64: maskCanvas.toDataURL("image/png").split(",")[1] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw an image to a canvas (downscaled) and return a JPEG Blob. */
|
||||||
|
function canvasBlob(image: ImageSource, 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 clamp01(n: number): number {
|
||||||
|
return n < 0 ? 0 : n > 1 ? 1 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Object detection — TensorFlow.js COCO-SSD, in-browser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CocoPrediction {
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
class: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
interface CocoModel {
|
||||||
|
detect(
|
||||||
|
img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement,
|
||||||
|
maxNumBoxes?: number,
|
||||||
|
minScore?: number,
|
||||||
|
): Promise<CocoPrediction[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cocoPromise: Promise<CocoModel | null> | null = null;
|
||||||
|
|
||||||
|
/** Load tfjs + COCO-SSD exactly once; resolves to null if anything fails. */
|
||||||
|
function ensureCoco(): Promise<CocoModel | null> {
|
||||||
|
cocoPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
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 (await cocoSsd.load({ base: "lite_mobilenet_v2" })) as unknown as CocoModel;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[gallery-ai] COCO-SSD load failed; object detection disabled.", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return cocoPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectObjectsInBrowser(
|
||||||
|
item: MediaItem,
|
||||||
|
image: ImageSource,
|
||||||
|
): Promise<DetectedObject[]> {
|
||||||
|
const model = await ensureCoco();
|
||||||
|
if (!model) return [];
|
||||||
|
try {
|
||||||
|
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 },
|
||||||
|
})) as DetectedObject[];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[gallery-ai] object detection failed.", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side YOLO detection via /api/gallery/ai/classify, with the in-browser
|
||||||
|
* COCO-SSD as the automatic fallback so detection never hard-fails.
|
||||||
|
*/
|
||||||
|
async function detectObjectsViaRunpod(
|
||||||
|
item: MediaItem,
|
||||||
|
image: ImageSource,
|
||||||
|
): Promise<DetectedObject[]> {
|
||||||
|
try {
|
||||||
|
const { data, mimeType, width, height } = imageToBase64(image, 1280);
|
||||||
|
const res = await fetch(`${API}/classify`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ imageBase64: data, mimeType, width, height }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`classify failed (${res.status})`);
|
||||||
|
const { objects } = (await res.json()) as { objects?: DetectedObject[] };
|
||||||
|
if (Array.isArray(objects)) return objects;
|
||||||
|
throw new Error("classify returned no objects");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[gallery-ai] RunPod detection failed; falling back to COCO-SSD.", err);
|
||||||
|
return detectObjectsInBrowser(item, image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Faces — @vladmandic/face-api, in-browser (128-D descriptors → People)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FACE_MODEL_URL = "https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model";
|
||||||
|
|
||||||
|
type FaceApi = typeof import("@vladmandic/face-api");
|
||||||
|
|
||||||
|
let facePromise: Promise<FaceApi | null> | null = null;
|
||||||
|
|
||||||
|
function ensureFaceModels(): Promise<FaceApi | null> {
|
||||||
|
facePromise ??= (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(FACE_MODEL_URL),
|
||||||
|
faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_URL),
|
||||||
|
faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_URL),
|
||||||
|
]);
|
||||||
|
return faceapi;
|
||||||
|
} catch (err) {
|
||||||
|
// Degrade gracefully — People simply stays empty if models can't load.
|
||||||
|
console.warn("[gallery-ai] face model load failed; face clustering disabled.", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return facePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
let faceWarned = false;
|
||||||
|
|
||||||
|
async function detectFaces(item: MediaItem, image: ImageSource): Promise<DetectedFace[]> {
|
||||||
|
const faceapi = await ensureFaceModels();
|
||||||
|
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;
|
||||||
|
|
||||||
|
type TNetInput = Parameters<FaceApi["detectAllFaces"]>[0];
|
||||||
|
|
||||||
|
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 (!faceWarned) {
|
||||||
|
faceWarned = true;
|
||||||
|
console.warn("[gallery-ai] 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),
|
||||||
|
};
|
||||||
|
}) as DetectedFace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OCR — tesseract.js, in-browser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Must equal the EXACT tesseract.js version in package.json (pinned, no caret)
|
||||||
|
// so the worker CDN URL can never drift from the installed main-thread code.
|
||||||
|
const TESSERACT_VERSION = "5.1.1";
|
||||||
|
const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${TESSERACT_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),
|
||||||
|
// so every asset comes from ONE host that a CSP can allow-list.
|
||||||
|
const LANG_PATH = "https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0";
|
||||||
|
|
||||||
|
interface OcrWord {
|
||||||
|
text?: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
interface OcrData {
|
||||||
|
text?: string;
|
||||||
|
confidence?: number;
|
||||||
|
words?: OcrWord[];
|
||||||
|
blocks?: Array<{ paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }> }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TesseractWorker = import("tesseract.js").Worker;
|
||||||
|
|
||||||
|
let ocrWorkerPromise: Promise<TesseractWorker | null> | null = null;
|
||||||
|
|
||||||
|
function ensureOcrWorker(): Promise<TesseractWorker | null> {
|
||||||
|
ocrWorkerPromise ??= (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("[gallery-ai] tesseract worker init failed; OCR disabled.", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return ocrWorkerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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). tesseract hallucinates low-confidence
|
||||||
|
* gibberish for photos with no text, so we keep only high-confidence, word-shaped
|
||||||
|
* tokens and require several of them.
|
||||||
|
*/
|
||||||
|
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 : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ocr(_item: MediaItem, image: ImageSource): Promise<string> {
|
||||||
|
const worker = await ensureOcrWorker();
|
||||||
|
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 (err) {
|
||||||
|
console.warn("[gallery-ai] OCR failed.", err);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Semantic search — CLIP via transformers.js (ONNX-WASM), in-browser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CLIP_MODEL_ID = "Xenova/clip-vit-base-patch16";
|
||||||
|
|
||||||
|
type Transformers = typeof import("@huggingface/transformers");
|
||||||
|
|
||||||
|
let transformersMod: Transformers | null = null;
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- transformers.js pipelines are untyped */
|
||||||
|
let clipVisionPromise: Promise<{ processor: any; model: any } | null> | null = null;
|
||||||
|
let clipTextPromise: Promise<{ tokenizer: any; model: any } | null> | null = null;
|
||||||
|
|
||||||
|
async function loadTransformers(): Promise<Transformers> {
|
||||||
|
if (!transformersMod) {
|
||||||
|
transformersMod = await import("@huggingface/transformers");
|
||||||
|
// Remote-only (models from the HF CDN); rely on browser cache between sessions.
|
||||||
|
transformersMod.env.allowLocalModels = false;
|
||||||
|
}
|
||||||
|
return transformersMod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureClipVision() {
|
||||||
|
clipVisionPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
const tf = await loadTransformers();
|
||||||
|
const [processor, model] = await Promise.all([
|
||||||
|
tf.AutoProcessor.from_pretrained(CLIP_MODEL_ID),
|
||||||
|
tf.CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL_ID),
|
||||||
|
]);
|
||||||
|
return { processor, model };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[gallery-ai] CLIP vision load failed; semantic search disabled.", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return clipVisionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureClipText() {
|
||||||
|
clipTextPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
const tf = await loadTransformers();
|
||||||
|
const [tokenizer, model] = await Promise.all([
|
||||||
|
tf.AutoTokenizer.from_pretrained(CLIP_MODEL_ID),
|
||||||
|
tf.CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL_ID),
|
||||||
|
]);
|
||||||
|
return { tokenizer, model };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[gallery-ai] CLIP text load failed; semantic search disabled.", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return clipTextPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw an image onto a canvas (downscaled) for the CLIP image processor. */
|
||||||
|
function toCanvas(image: ImageSource, 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");
|
||||||
|
if (!ctx) throw new Error("Canvas not supported.");
|
||||||
|
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>);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedImage(_item: MediaItem, image: ImageSource): Promise<number[]> {
|
||||||
|
const v = await ensureClipVision();
|
||||||
|
if (!v) return [];
|
||||||
|
try {
|
||||||
|
const tf = await loadTransformers();
|
||||||
|
const canvas = toCanvas(image);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return [];
|
||||||
|
const imageData = ctx.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("[gallery-ai] embedImage failed.", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedText(query: string): Promise<number[]> {
|
||||||
|
const t = await ensureClipText();
|
||||||
|
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("[gallery-ai] embedText failed.", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** `true` only for the literal string "true", matching the SDK demo's semantics. */
|
||||||
|
function flag(v: string | undefined): boolean {
|
||||||
|
return v === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCrmAIProvider(): AIProvider {
|
||||||
|
// NEXT_PUBLIC_* are inlined at build time, so these must be read as full
|
||||||
|
// static member expressions — do NOT refactor to dynamic indexing.
|
||||||
|
const useRunpodDetect = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_DETECT);
|
||||||
|
const useRunpodBg = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_BG);
|
||||||
|
const useRunpodTilt = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_TILT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "crm-ai (coco-ssd/yolo + face-api + tesseract + clip + runpod-edit)",
|
||||||
|
|
||||||
|
detectObjects: useRunpodDetect ? detectObjectsViaRunpod : detectObjectsInBrowser,
|
||||||
|
detectFaces,
|
||||||
|
ocr,
|
||||||
|
embedImage,
|
||||||
|
embedText,
|
||||||
|
|
||||||
|
async generativeEdit(item: MediaItem, image: ImageSource, op: GenerativeEditOp) {
|
||||||
|
// Remove Background runs fully in-browser (no key) via @imgly — works even
|
||||||
|
// with no backend. Other ops go through the /api/gallery/ai/edit route.
|
||||||
|
if (op.type === "remove-background") {
|
||||||
|
// Prefer the RunPod U²-Net endpoint when enabled; fall back to in-browser
|
||||||
|
// @imgly if it's off or the request fails, so this always produces a result.
|
||||||
|
if (useRunpodBg) {
|
||||||
|
try {
|
||||||
|
const { data, mimeType } = imageToBase64(image, 1600);
|
||||||
|
const res = await fetch(`${API}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageBase64: data,
|
||||||
|
mimeType,
|
||||||
|
op: { type: "remove-background" },
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const { imageBase64: out, mimeType: outMime } = (await res.json()) as {
|
||||||
|
imageBase64: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
return base64ToBlob(out, outMime || "image/png");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to the in-browser remover */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inputBlob = await canvasBlob(image, 1600);
|
||||||
|
const { removeBackground } = await import("@imgly/background-removal");
|
||||||
|
return removeBackground(inputBlob, { output: { format: "image/png" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outpaint / expand-canvas: pad the image with a neutral border, mark that
|
||||||
|
// border WHITE in the mask, and run it through the same inpaint path as
|
||||||
|
// generative-fill — no extra backend route needed.
|
||||||
|
if (op.type === "outpaint") {
|
||||||
|
const { imageBase64: padded, maskBase64: border } = padForOutpaint(
|
||||||
|
image,
|
||||||
|
typeof op.factor === "number" ? op.factor : 1.5,
|
||||||
|
);
|
||||||
|
const outParams: Record<string, unknown> = {
|
||||||
|
strength: typeof op.strength === "number" ? op.strength : 0.85,
|
||||||
|
};
|
||||||
|
const outRes = await fetch(`${API}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageBase64: padded,
|
||||||
|
mimeType: "image/png",
|
||||||
|
op: {
|
||||||
|
type: "generative-fill",
|
||||||
|
prompt:
|
||||||
|
op.prompt ||
|
||||||
|
"Extend and continue the scene naturally, matching lighting, colors and perspective.",
|
||||||
|
},
|
||||||
|
maskBase64: border,
|
||||||
|
params: outParams,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!outRes.ok) {
|
||||||
|
const err = (await outRes.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(err.error || `AI request failed (${outRes.status}).`);
|
||||||
|
}
|
||||||
|
const outJson = (await outRes.json()) as { imageBase64: string; mimeType?: string };
|
||||||
|
return base64ToBlob(outJson.imageBase64, outJson.mimeType || "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, mimeType, width, height } = imageToBase64(image, 1280);
|
||||||
|
// Masked ops carry an ImageData mask — rasterize it to a PNG matched to the
|
||||||
|
// (downscaled) image dims, and strip it from the op since ImageData is not
|
||||||
|
// JSON-serializable.
|
||||||
|
const maskBase64 = "mask" in op ? maskToBase64(op.mask, width, height) : undefined;
|
||||||
|
const wireOp: Record<string, unknown> = { type: op.type };
|
||||||
|
if ("prompt" in op && typeof op.prompt === "string") wireOp.prompt = op.prompt;
|
||||||
|
if ("factor" in op && typeof op.factor === "number") wireOp.factor = op.factor;
|
||||||
|
|
||||||
|
// Forward the "edit strength" slider (0..1) so the backend can scale the edit.
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if ("strength" in op && typeof op.strength === "number") params.strength = op.strength;
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ imageBase64: data, mimeType, op: wireOp, maskBase64, params }),
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Voice annotation: record → (optional denoise) → transcribe. Both proxy
|
||||||
|
// through server routes so the RunPod key stays server-side.
|
||||||
|
async transcribeAudio(audioBase64: string) {
|
||||||
|
const res = await fetch(`${API}/transcribe`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ audio: audioBase64 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(err.error || `Transcription failed (${res.status}).`);
|
||||||
|
}
|
||||||
|
const { transcript } = (await res.json()) as { transcript?: string };
|
||||||
|
return (transcript ?? "").trim();
|
||||||
|
},
|
||||||
|
|
||||||
|
async denoiseAudio(audioBase64: string) {
|
||||||
|
const res = await fetch(`${API}/denoise`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ audio: audioBase64 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(err.error || `Denoise failed (${res.status}).`);
|
||||||
|
}
|
||||||
|
const { audio } = (await res.json()) as { audio?: string };
|
||||||
|
return audio ?? audioBase64;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Camera-tilt estimation is opt-in (needs the RunPod tilt endpoint deployed);
|
||||||
|
// gate it so the editor's Auto-straighten button only appears when configured.
|
||||||
|
estimateTilt: useRunpodTilt
|
||||||
|
? async (_item: MediaItem, image: ImageSource) => {
|
||||||
|
const { data } = imageToBase64(image, 1024);
|
||||||
|
const res = await fetch(`${API}/tilt`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ image: data }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(err.error || `Tilt estimate failed (${res.status}).`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as {
|
||||||
|
rollDegrees: number;
|
||||||
|
pitchDegrees: number;
|
||||||
|
fovDegrees: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Smart Gallery data layer. Serves EITHER a device-local mock (when the Shell isn't configured — the
|
||||||
|
// demo keeps working offline) OR the live be-crm data door (crm.gallery.*), behind one StorageAdapter
|
||||||
|
// so the embedded @photo-gallery/sdk is mode-agnostic. Tenant scoping, comment authorship and byte
|
||||||
|
// authorization are all enforced server-side; this is just glue.
|
||||||
|
//
|
||||||
|
// Live contract (be-crm):
|
||||||
|
// query crm.gallery.state.load {} -> PersistedState
|
||||||
|
// cmd crm.gallery.state.apply StateChanges -> { upserted, removed }
|
||||||
|
// cmd crm.gallery.media.presignUpload { mediaId, mime, sizeBytes, filename? } -> { ref, uploadUrl, method, headers? }
|
||||||
|
// cmd crm.gallery.media.presignDownload { refs: string[] } -> { urls, expiresInSeconds }
|
||||||
|
// query crm.gallery.stats {} -> { items, albums, people, bytes }
|
||||||
|
// query crm.gallery.lock.status {} -> { hasPassword }
|
||||||
|
// cmd crm.gallery.lock.set { password: string | null } -> { hasPassword }
|
||||||
|
// cmd crm.gallery.lock.verify { password: string } -> { ok }
|
||||||
|
//
|
||||||
|
// BYTES NEVER PASS THROUGH be-crm OR THE BFF. `putMedia` mints a short-lived signed PUT URL and the
|
||||||
|
// browser transfers straight to object storage — the same rule media-api.ts follows for attachments.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import {
|
||||||
|
createLocalStorageAdapter,
|
||||||
|
type GalleryFeatures,
|
||||||
|
type GalleryUser,
|
||||||
|
type MediaItem,
|
||||||
|
type PersistedState,
|
||||||
|
type StateChanges,
|
||||||
|
type StorageAdapter,
|
||||||
|
type StoredBlob,
|
||||||
|
type ThemeTokens,
|
||||||
|
} from "@photo-gallery/sdk";
|
||||||
|
import { user as demoUser } from "@/components/dashboard/account-data";
|
||||||
|
import { useMyAccess } from "@/lib/access";
|
||||||
|
import { isShellConfigured } from "./appshell";
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
/** Device-local store key for demo mode. Namespaced so it can't collide with the SDK's own demo. */
|
||||||
|
const LOCAL_STORE_KEY = "lup:smart-gallery:v1";
|
||||||
|
|
||||||
|
/** be-crm caps a single presignDownload at 500 refs — chunk anything larger. */
|
||||||
|
const PRESIGN_CHUNK = 500;
|
||||||
|
|
||||||
|
/** Largest single upload the gallery will attempt (matches be-crm's gallery cap). */
|
||||||
|
export const MAX_GALLERY_BYTES = 200 * 1024 * 1024;
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* Wire types (be-crm shapes) */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
interface StateLoadDTO {
|
||||||
|
media: MediaItem[];
|
||||||
|
albums: PersistedState["albums"];
|
||||||
|
people: PersistedState["people"];
|
||||||
|
labelAliases?: Record<string, string>;
|
||||||
|
deletedLabels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresignUploadDTO {
|
||||||
|
ref: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
method: "PUT";
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresignDownloadDTO {
|
||||||
|
urls: Record<string, string>;
|
||||||
|
expiresInSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of the AppShell SDK this module needs — keeps the adapter unit-testable. */
|
||||||
|
interface DataDoor {
|
||||||
|
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||||
|
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* The live adapter — be-crm data door */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A StorageAdapter backed by the be-crm data door.
|
||||||
|
*
|
||||||
|
* Metadata (media/albums/people) rides the door as JSON; bytes go direct to object storage via
|
||||||
|
* short-lived signed URLs. `storageRef` is the durable handle we persist — `src` is only ever a
|
||||||
|
* signed URL with a TTL, so it is re-resolved from the refs on every `load()`.
|
||||||
|
*/
|
||||||
|
export function createDataDoorAdapter(sdk: DataDoor): StorageAdapter {
|
||||||
|
/** Resolve durable refs → fresh signed GET URLs, in chunks the door will accept. */
|
||||||
|
async function resolveRefs(refs: string[]): Promise<Record<string, string>> {
|
||||||
|
const unique = [...new Set(refs.filter(Boolean))];
|
||||||
|
if (!unique.length) return {};
|
||||||
|
const urls: Record<string, string> = {};
|
||||||
|
for (const group of chunk(unique, PRESIGN_CHUNK)) {
|
||||||
|
const res = await sdk.command<PresignDownloadDTO>("crm.gallery.media.presignDownload", { refs: group });
|
||||||
|
Object.assign(urls, res.urls ?? {});
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "crm-data-door",
|
||||||
|
|
||||||
|
async load(): Promise<PersistedState | null> {
|
||||||
|
const state = await sdk.query<StateLoadDTO>("crm.gallery.state.load", {});
|
||||||
|
const media = state.media ?? [];
|
||||||
|
// Signed URLs expire, so `src` is always rebuilt from `storageRef` at load time. Items with no
|
||||||
|
// ref (e.g. a seeded remote URL) keep whatever `src` they were stored with.
|
||||||
|
const urls = await resolveRefs(media.map((m) => m.storageRef ?? "").filter(Boolean));
|
||||||
|
return {
|
||||||
|
media: media.map((m) => (m.storageRef && urls[m.storageRef] ? { ...m, src: urls[m.storageRef]! } : m)),
|
||||||
|
albums: state.albums ?? [],
|
||||||
|
people: state.people ?? [],
|
||||||
|
labelAliases: state.labelAliases ?? {},
|
||||||
|
deletedLabels: state.deletedLabels ?? [],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Incremental sync is the real path (see applyChanges). `save` only runs if the store ever falls
|
||||||
|
// back to whole-state persistence; express it as one big change set so behaviour is identical.
|
||||||
|
async save(state: PersistedState): Promise<void> {
|
||||||
|
await sdk.command("crm.gallery.state.apply", {
|
||||||
|
upsertMedia: state.media,
|
||||||
|
upsertAlbums: state.albums,
|
||||||
|
upsertPeople: state.people,
|
||||||
|
labelAliases: state.labelAliases ?? {},
|
||||||
|
deletedLabels: state.deletedLabels ?? [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental persistence — only the entities that actually changed. This is what makes a shared
|
||||||
|
* tenant library safe for concurrent editors: two people touching different photos never clobber
|
||||||
|
* each other, because neither sends the other's rows.
|
||||||
|
*/
|
||||||
|
async applyChanges(changes: StateChanges): Promise<void> {
|
||||||
|
// StateChanges is a closed interface; the door takes an open variables bag.
|
||||||
|
await sdk.command("crm.gallery.state.apply", { ...changes });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Presign → direct PUT → resolve a display URL. Bytes never touch be-crm or the BFF. */
|
||||||
|
async putMedia(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob> {
|
||||||
|
if (blob.size > MAX_GALLERY_BYTES) {
|
||||||
|
throw new Error(`File is too large (max ${Math.floor(MAX_GALLERY_BYTES / (1024 * 1024))} MB).`);
|
||||||
|
}
|
||||||
|
const mime = meta.mime || blob.type || "application/octet-stream";
|
||||||
|
const presigned = await sdk.command<PresignUploadDTO>("crm.gallery.media.presignUpload", {
|
||||||
|
mediaId: id,
|
||||||
|
mime,
|
||||||
|
sizeBytes: blob.size,
|
||||||
|
filename: meta.name,
|
||||||
|
});
|
||||||
|
const res = await fetch(presigned.uploadUrl, {
|
||||||
|
method: presigned.method ?? "PUT",
|
||||||
|
headers: { "content-type": mime, ...(presigned.headers ?? {}) },
|
||||||
|
body: blob,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||||
|
const urls = await resolveRefs([presigned.ref]);
|
||||||
|
return { ref: presigned.ref, url: urls[presigned.ref] ?? presigned.ref };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* Public hooks */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
export interface GalleryStorage {
|
||||||
|
/** True when persisting to be-crm; false when running on the device-local demo store. */
|
||||||
|
live: boolean;
|
||||||
|
adapter: StorageAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage backend for the embedded gallery. Live against the be-crm data door once the Shell is
|
||||||
|
* configured, otherwise a device-local store so the demo works with no backend at all.
|
||||||
|
*
|
||||||
|
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
|
||||||
|
*/
|
||||||
|
export function useGalleryStorage(): GalleryStorage {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
// The adapter identity must be stable — PhotoGallery captures it in a ref on first render.
|
||||||
|
return useMemo<GalleryStorage>(
|
||||||
|
() =>
|
||||||
|
SHELL
|
||||||
|
? { live: true, adapter: createDataDoorAdapter(sdk as unknown as DataDoor) }
|
||||||
|
: { live: false, adapter: createLocalStorageAdapter(LOCAL_STORE_KEY) },
|
||||||
|
[sdk],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in identity handed to the gallery so comments are attributed to a real person rather
|
||||||
|
* than a free-text name the author can type themselves.
|
||||||
|
*
|
||||||
|
* Falls back to the same static demo user the sidebar and topbar use when the Shell isn't wired, so
|
||||||
|
* the comment module behaves identically in the demo — the CRM never shows an anonymous author.
|
||||||
|
* be-crm re-stamps `authorId` from the PAT on every write regardless, so this value is a display
|
||||||
|
* convenience, never the source of authority.
|
||||||
|
*/
|
||||||
|
export function useGalleryUser(): GalleryUser {
|
||||||
|
const { user, context } = useAuth();
|
||||||
|
return useMemo<GalleryUser>(() => {
|
||||||
|
if (!user) return { id: demoUser.id, name: demoUser.name };
|
||||||
|
const avatarUrl = user.avatarUrl ?? context?.principal?.avatarUrl;
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.displayName || user.email || demoUser.name,
|
||||||
|
...(user.email ? { email: user.email } : {}),
|
||||||
|
...(avatarUrl ? { avatarUrl } : {}),
|
||||||
|
};
|
||||||
|
}, [user, context]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* Recently Deleted lock */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SDK's `lockProvider` contract, declared here rather than imported so this module keeps
|
||||||
|
* compiling against an SDK build that predates the prop. It is structurally identical to
|
||||||
|
* `PhotoGalleryProps['lockProvider']`.
|
||||||
|
*/
|
||||||
|
export interface GalleryLockProvider {
|
||||||
|
status(): Promise<{ hasPassword: boolean }>;
|
||||||
|
/** `null` clears the password. */
|
||||||
|
set(password: string | null): Promise<void>;
|
||||||
|
verify(password: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A server-backed, per-user lock for the Recently Deleted view.
|
||||||
|
*
|
||||||
|
* Without this the SDK falls back to a device-local `localStorage` hash: the lock exists only on
|
||||||
|
* the browser that set it, so the same account on a second device sees an unlocked trash. Backed
|
||||||
|
* by `crm.gallery.lock.*`, the password becomes an account property — be-crm stores only a
|
||||||
|
* scrypt hash with a per-record random salt, keyed by (tenant, principal), and rate-limits verify.
|
||||||
|
*
|
||||||
|
* Returns `undefined` in demo mode ON PURPOSE: with no backend there is nowhere to put the hash,
|
||||||
|
* and the SDK's own device-local behaviour is the right fallback for a demo.
|
||||||
|
*
|
||||||
|
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
|
||||||
|
*/
|
||||||
|
export function useGalleryLockProvider(): GalleryLockProvider | undefined {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
return useMemo<GalleryLockProvider | undefined>(() => {
|
||||||
|
if (!SHELL) return undefined;
|
||||||
|
const door = sdk as unknown as DataDoor;
|
||||||
|
return {
|
||||||
|
status: () => door.query<{ hasPassword: boolean }>("crm.gallery.lock.status", {}),
|
||||||
|
// The SDK's contract returns void; the door's `{ hasPassword }` is redundant after a set.
|
||||||
|
set: async (password) => {
|
||||||
|
await door.command("crm.gallery.lock.set", { password });
|
||||||
|
},
|
||||||
|
// A wrong password is a normal `{ ok: false }`, not an error. A 403 (the verify lockout)
|
||||||
|
// still throws, which is what the SDK's prompt should surface.
|
||||||
|
verify: async (password) => (await door.command<{ ok: boolean }>("crm.gallery.lock.verify", { password })).ok,
|
||||||
|
};
|
||||||
|
}, [sdk]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* Permission-gated features */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gallery capabilities the CRM gates behind team permissions. Each SDK feature toggle
|
||||||
|
* (and the whole-view `media.view` gate) maps to one permission id from be-crm's Media group.
|
||||||
|
* The SDK still enforces nothing here — hiding a control is UX; be-crm enforces every write via
|
||||||
|
* GALLERY_VISIBILITY + tenant scoping regardless of what the UI shows.
|
||||||
|
*/
|
||||||
|
const MEDIA_PERMISSIONS = [
|
||||||
|
"media.view", "media.upload", "media.capture", "media.edit",
|
||||||
|
"media.delete", "media.export", "media.share", "media.map", "media.ai",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** SDK feature toggle → the permission id that unlocks it. */
|
||||||
|
const FEATURE_PERMISSION: Record<keyof GalleryFeatures, string> = {
|
||||||
|
editor: "media.edit",
|
||||||
|
camera: "media.capture",
|
||||||
|
import: "media.upload",
|
||||||
|
export: "media.export",
|
||||||
|
sharing: "media.share",
|
||||||
|
map: "media.map",
|
||||||
|
ai: "media.ai",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResolvedGalleryFeatures {
|
||||||
|
/** Feature toggles to hand the SDK's `features` prop, resolved from the caller's permissions. */
|
||||||
|
features: GalleryFeatures;
|
||||||
|
/** Whether the whole Smart Gallery view should render at all (`media.view`). */
|
||||||
|
canView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the gallery's feature flags + view access from the signed-in user's CRM permissions.
|
||||||
|
*
|
||||||
|
* Fallback rule (SAFE default — a control is enabled unless the user is positively known to lack it):
|
||||||
|
* 1. While access is still loading, stay permissive so features don't flash on then vanish.
|
||||||
|
* 2. Superadmins and owners get everything (they hold every permission anyway; this is explicit).
|
||||||
|
* 3. A member who has been assigned AT LEAST ONE `media.*` permission is gated precisely: each
|
||||||
|
* feature is enabled only if its mapped permission is in their list.
|
||||||
|
* 4. A member with ZERO `media.*` permissions assigned is treated as fully enabled. Roles are not
|
||||||
|
* configured with Media perms until an admin opts in (§5 just made them assignable), so gating a
|
||||||
|
* freshly-seeded member down to nothing would cripple the gallery before anyone could grant them
|
||||||
|
* anything. The "has at least one media.* perm" signal is what flips a role from this permissive
|
||||||
|
* default into precise per-feature gating.
|
||||||
|
*
|
||||||
|
* Because be-crm's mock access (demo, no Shell) returns superadmin + all perms, the demo shows
|
||||||
|
* everything via rule 2.
|
||||||
|
*/
|
||||||
|
export function useGalleryFeatures(): ResolvedGalleryFeatures {
|
||||||
|
const access = useMyAccess();
|
||||||
|
return useMemo<ResolvedGalleryFeatures>(() => {
|
||||||
|
const has = (p: string) => access.permissions.includes(p);
|
||||||
|
const privileged = access.isSuperadmin || access.roleSlugs.includes("owner");
|
||||||
|
const hasAnyMedia = MEDIA_PERMISSIONS.some(has);
|
||||||
|
// Permissive whenever we can't (yet) prove the user lacks a permission: loading, privileged, or a
|
||||||
|
// member who has no Media perms assigned at all. Otherwise gate precisely on the mapped id.
|
||||||
|
const allow = (perm: string) => access.loading || privileged || !hasAnyMedia || has(perm);
|
||||||
|
|
||||||
|
const features: GalleryFeatures = {
|
||||||
|
editor: allow(FEATURE_PERMISSION.editor),
|
||||||
|
camera: allow(FEATURE_PERMISSION.camera),
|
||||||
|
ai: allow(FEATURE_PERMISSION.ai),
|
||||||
|
map: allow(FEATURE_PERMISSION.map),
|
||||||
|
import: allow(FEATURE_PERMISSION.import),
|
||||||
|
export: allow(FEATURE_PERMISSION.export),
|
||||||
|
sharing: allow(FEATURE_PERMISSION.sharing),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { features, canView: allow("media.view") };
|
||||||
|
}, [access]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* Theme bridge */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the gallery's design tokens onto the CRM's own CSS variables.
|
||||||
|
*
|
||||||
|
* Every value is a `var(--crm-token)` reference rather than a literal hex, so the gallery inherits the
|
||||||
|
* dashboard's palette through the SAME `[data-theme]` cascade the rest of the app uses — light/dark
|
||||||
|
* switch together, and a future palette change reaches the gallery with no code edit here.
|
||||||
|
*/
|
||||||
|
export const GALLERY_THEME_TOKENS: ThemeTokens = {
|
||||||
|
// Surfaces
|
||||||
|
bgLight: "var(--bg)",
|
||||||
|
bgDark: "var(--bg)",
|
||||||
|
elevatedLight: "var(--panel-3)",
|
||||||
|
elevatedDark: "var(--panel-3)",
|
||||||
|
sidebarBgLight: "var(--sidebar)",
|
||||||
|
sidebarBgDark: "var(--sidebar)",
|
||||||
|
toolbarBgLight: "var(--panel)",
|
||||||
|
toolbarBgDark: "var(--panel)",
|
||||||
|
cardLight: "var(--panel)",
|
||||||
|
cardDark: "var(--panel)",
|
||||||
|
cardHoverLight: "var(--panel-3)",
|
||||||
|
cardHoverDark: "var(--panel-3)",
|
||||||
|
menuBgLight: "var(--panel-3)",
|
||||||
|
menuBgDark: "var(--panel-3)",
|
||||||
|
|
||||||
|
// Text
|
||||||
|
textLight: "var(--text)",
|
||||||
|
textDark: "var(--text)",
|
||||||
|
textSecondaryLight: "var(--muted)",
|
||||||
|
textSecondaryDark: "var(--muted)",
|
||||||
|
textTertiaryLight: "var(--faint)",
|
||||||
|
textTertiaryDark: "var(--faint)",
|
||||||
|
|
||||||
|
// Lines + washes
|
||||||
|
separatorLight: "var(--border)",
|
||||||
|
separatorDark: "var(--border)",
|
||||||
|
separatorStrongLight: "var(--border-2)",
|
||||||
|
separatorStrongDark: "var(--border-2)",
|
||||||
|
hoverLight: "var(--track)",
|
||||||
|
hoverDark: "var(--track)",
|
||||||
|
activeLight: "var(--border-2)",
|
||||||
|
activeDark: "var(--border-2)",
|
||||||
|
sidebarSelectedLight: "var(--track)",
|
||||||
|
sidebarSelectedDark: "var(--track)",
|
||||||
|
glassBorderLight: "var(--border-2)",
|
||||||
|
glassBorderDark: "var(--border-2)",
|
||||||
|
|
||||||
|
// Brand
|
||||||
|
accent: "var(--orange)",
|
||||||
|
accentStrongLight: "var(--orange-2)",
|
||||||
|
accentStrongDark: "var(--orange-2)",
|
||||||
|
accentContrast: "#ffffff",
|
||||||
|
dangerLight: "var(--red)",
|
||||||
|
dangerDark: "var(--red)",
|
||||||
|
tileFav: "var(--red)",
|
||||||
|
segmentedActive: "var(--panel-3)",
|
||||||
|
|
||||||
|
// Overlays + chrome
|
||||||
|
overlayBg: "rgba(2,2,6,0.94)",
|
||||||
|
editorBg: "var(--panel-2)",
|
||||||
|
shadowSm: "0 1px 2px rgba(0,0,0,0.18)",
|
||||||
|
shadowMdLight: "var(--shadow)",
|
||||||
|
shadowMdDark: "var(--shadow)",
|
||||||
|
shadowLgLight: "0 30px 70px -20px rgba(15,23,42,0.25)",
|
||||||
|
shadowLgDark: "0 30px 70px -20px rgba(0,0,0,0.7)",
|
||||||
|
|
||||||
|
fontFamily: "var(--font)",
|
||||||
|
radiusMenu: 14,
|
||||||
|
sidebarRadius: 14,
|
||||||
|
};
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Projects data layer. Serves EITHER the local mock (when the Shell isn't
|
|
||||||
// configured — the polished demo keeps working) OR the live be-crm data door
|
|
||||||
// (crm.project.*), behind one interface so the component is mode-agnostic.
|
|
||||||
// Mirrors team-api.ts.
|
|
||||||
//
|
|
||||||
// Live contract (be-crm):
|
|
||||||
// query crm.project.list { perPage } -> { items: ProjectDTO[], meta }
|
|
||||||
// cmd crm.project.create { name, status?, value?, address?, dueDate?, ... }
|
|
||||||
// cmd crm.project.setStatus { id, status }
|
|
||||||
// cmd crm.project.remove { id }
|
|
||||||
//
|
|
||||||
// be-crm's Project model is generic (name/status/value/owner/address/dates) —
|
|
||||||
// it has no stage, job type, progress or health score. Those are mock-only
|
|
||||||
// fields (undefined in live mode); the Projects screen renders a leaner
|
|
||||||
// column set when `live` is true.
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
import {
|
|
||||||
collectionsFor, constructionLeads, pipelineLeads,
|
|
||||||
type CollectionRecord, type ConstructionStatus,
|
|
||||||
} from "@/components/dashboard/projects-data";
|
|
||||||
|
|
||||||
export type UiCollectionRecord = CollectionRecord;
|
|
||||||
|
|
||||||
/* ---- UI-facing shapes ---------------------------------------------------- */
|
|
||||||
|
|
||||||
// "lead" / "archived" round out the mock's four (active/complete/stuck/followup)
|
|
||||||
// so every be-crm ProjectStatus has somewhere to map to.
|
|
||||||
export type UiProjectStatus = ConstructionStatus | "lead" | "archived";
|
|
||||||
|
|
||||||
export interface UiProject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string | null;
|
|
||||||
status: UiProjectStatus;
|
|
||||||
stage: string | null; // mock only
|
|
||||||
jobType: string | null; // mock only
|
|
||||||
agent: string;
|
|
||||||
progress: number | null; // mock only
|
|
||||||
health: number | null; // mock only
|
|
||||||
value: number | null; // dollars
|
|
||||||
dueDate: string | null;
|
|
||||||
createdAgo: string | null; // mock pipeline leads only
|
|
||||||
collections: UiCollectionRecord[] | null; // mock only — no billing backend yet
|
|
||||||
isLead: boolean; // true → "Pipeline Leads" tab
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewProjectInput {
|
|
||||||
name: string;
|
|
||||||
status: UiProjectStatus;
|
|
||||||
value?: number; // dollars
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectsData {
|
|
||||||
live: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
projects: UiProject[];
|
|
||||||
create: (p: NewProjectInput) => Promise<void>;
|
|
||||||
setStatus: (id: string, status: UiProjectStatus) => Promise<void>;
|
|
||||||
remove: (id: string) => Promise<void>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STATUS_LABEL: Record<UiProjectStatus, string> = {
|
|
||||||
lead: "Lead", active: "Active", complete: "Complete", stuck: "Stuck", followup: "Follow-up", archived: "Archived",
|
|
||||||
};
|
|
||||||
export const STATUS_TONE: Record<UiProjectStatus, string> = {
|
|
||||||
lead: "purple", active: "green", complete: "blue", stuck: "red", followup: "orange", archived: "muted",
|
|
||||||
};
|
|
||||||
// Quick-filter chips only cover the four "in-flight job" statuses (mirrors the design).
|
|
||||||
export const QUICK_FILTERS: UiProjectStatus[] = ["active", "complete", "stuck", "followup"];
|
|
||||||
|
|
||||||
/* ---- mock → UI ------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function mockConstruction(c: (typeof constructionLeads)[number], index: number): UiProject {
|
|
||||||
return {
|
|
||||||
id: c.id, name: c.name, address: c.address, status: c.status, stage: c.stage, jobType: c.jobType,
|
|
||||||
agent: c.agent, progress: c.progress, health: c.health, value: c.value, dueDate: null, createdAgo: null,
|
|
||||||
collections: collectionsFor(c, index), isLead: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function mockPipeline(p: (typeof pipelineLeads)[number]): UiProject {
|
|
||||||
return {
|
|
||||||
id: p.id, name: p.name, address: p.address, status: "lead", stage: p.stage, jobType: p.jobType,
|
|
||||||
agent: p.agent, progress: null, health: null, value: null, dueDate: null, createdAgo: p.createdAgo,
|
|
||||||
collections: [], isLead: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Mock implementation (no Shell configured) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
let mockSeq = 1;
|
|
||||||
|
|
||||||
function useMockProjects(): ProjectsData {
|
|
||||||
const [projects, setProjects] = useState<UiProject[]>(() => [
|
|
||||||
...constructionLeads.map(mockConstruction),
|
|
||||||
...pipelineLeads.map(mockPipeline),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const create = useCallback(async (p: NewProjectInput) => {
|
|
||||||
const isLead = p.status === "lead";
|
|
||||||
const next: UiProject = {
|
|
||||||
id: `new_${mockSeq++}`, name: p.name, address: null, status: p.status,
|
|
||||||
stage: isLead ? "New Inquiry" : "New Lead", jobType: null, agent: "Unassigned",
|
|
||||||
progress: isLead ? null : 0, health: isLead ? null : 70, value: isLead ? null : (p.value ?? null),
|
|
||||||
dueDate: null, createdAgo: isLead ? "Just now" : null, collections: [], isLead,
|
|
||||||
};
|
|
||||||
setProjects((l) => [next, ...l]);
|
|
||||||
}, []);
|
|
||||||
const setStatus = useCallback(async (id: string, status: UiProjectStatus) => {
|
|
||||||
setProjects((l) => l.map((p) => (p.id === id ? { ...p, status, isLead: status === "lead" } : p)));
|
|
||||||
}, []);
|
|
||||||
const remove = useCallback(async (id: string) => { setProjects((l) => l.filter((p) => p.id !== id)); }, []);
|
|
||||||
|
|
||||||
return { live: false, loading: false, error: null, projects, create, setStatus, remove, refetch: () => {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Live implementation (be-crm data door) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
type BackendStatus = "lead" | "active" | "on_hold" | "won" | "lost" | "archived";
|
|
||||||
const BACKEND_TO_UI: Record<BackendStatus, UiProjectStatus> = {
|
|
||||||
lead: "lead", active: "active", on_hold: "stuck", won: "complete", lost: "followup", archived: "archived",
|
|
||||||
};
|
|
||||||
const UI_TO_BACKEND: Record<UiProjectStatus, BackendStatus> = {
|
|
||||||
lead: "lead", active: "active", stuck: "on_hold", complete: "won", followup: "lost", archived: "archived",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProjectAddressDTO { line1?: string | null; city?: string | null; state?: string | null; postalCode?: string | null }
|
|
||||||
interface ProjectDTO {
|
|
||||||
id: string; name: string; status: BackendStatus; ownerPrincipalId: string | null;
|
|
||||||
value: number | null; address: ProjectAddressDTO | null; dueDate: string | null;
|
|
||||||
}
|
|
||||||
interface MemberRef { principalId: string; displayName: string | null; jobTitle: string | null }
|
|
||||||
|
|
||||||
const fmtAddress = (a: ProjectAddressDTO | null): string | null => {
|
|
||||||
if (!a) return null;
|
|
||||||
const cityState = [a.city, a.state].filter(Boolean).join(" ");
|
|
||||||
const parts = [a.line1, [cityState, a.postalCode].filter(Boolean).join(" ")].filter(Boolean);
|
|
||||||
return parts.length ? parts.join(", ") : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useLiveProjects(): ProjectsData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const listQ = useQuery<{ items: ProjectDTO[] }>("crm.project.list", { perPage: 100 });
|
|
||||||
// Resolve ownerPrincipalId → a real name for the "Owner" column (crm.project.list
|
|
||||||
// only returns the raw id).
|
|
||||||
const membersQ = useQuery<{ items: MemberRef[] }>("crm.team.member.search", { perPage: 100 });
|
|
||||||
|
|
||||||
const refetch = useCallback(() => { listQ.refetch(); membersQ.refetch(); }, [listQ, membersQ]);
|
|
||||||
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
|
|
||||||
await sdk.command(action, variables);
|
|
||||||
refetch();
|
|
||||||
}, [sdk, refetch]);
|
|
||||||
|
|
||||||
const nameByPrincipal = useMemo(() => {
|
|
||||||
const m = new Map<string, string>();
|
|
||||||
for (const it of membersQ.data?.items ?? []) if (it.principalId) m.set(it.principalId, it.displayName || it.jobTitle || it.principalId);
|
|
||||||
return m;
|
|
||||||
}, [membersQ.data]);
|
|
||||||
|
|
||||||
const projects: UiProject[] = useMemo(() => (listQ.data?.items ?? []).map((d) => ({
|
|
||||||
id: d.id, name: d.name, address: fmtAddress(d.address),
|
|
||||||
status: BACKEND_TO_UI[d.status] ?? "active",
|
|
||||||
stage: null, jobType: null,
|
|
||||||
agent: d.ownerPrincipalId ? (nameByPrincipal.get(d.ownerPrincipalId) ?? "Unassigned") : "Unassigned",
|
|
||||||
progress: null, health: null,
|
|
||||||
value: d.value != null ? d.value / 100 : null, // minor units → dollars
|
|
||||||
dueDate: d.dueDate, createdAgo: null, collections: null, isLead: d.status === "lead",
|
|
||||||
})), [listQ.data, nameByPrincipal]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: true,
|
|
||||||
loading: listQ.loading || membersQ.loading,
|
|
||||||
error: (listQ.error ?? membersQ.error)?.message ?? null,
|
|
||||||
projects,
|
|
||||||
create: (p) => cmd("crm.project.create", {
|
|
||||||
name: p.name, status: UI_TO_BACKEND[p.status],
|
|
||||||
...(p.value != null ? { value: Math.round(p.value * 100) } : {}),
|
|
||||||
}),
|
|
||||||
setStatus: (id, status) => cmd("crm.project.setStatus", { id, status: UI_TO_BACKEND[status] }),
|
|
||||||
remove: (id) => cmd("crm.project.remove", { id }),
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- public hook: pick the implementation at module-config time --------- */
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
|
|
||||||
export function useProjectsData(): ProjectsData {
|
|
||||||
return SHELL ? useLiveProjects() : useMockProjects();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Tiny in-process fixed-window rate limiter for the Smart Gallery AI routes.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* SCOPE AND LIMITATIONS — READ BEFORE RELYING ON THIS
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* State lives in a plain `Map` in THIS process's memory. That means:
|
||||||
|
*
|
||||||
|
* - PER-INSTANCE, NOT GLOBAL. With N app instances behind a load balancer a
|
||||||
|
* caller gets up to N x the configured budget. On serverless platforms each
|
||||||
|
* cold start begins with an empty map, so the effective limit is weaker
|
||||||
|
* still.
|
||||||
|
* - NOT DURABLE. A restart or redeploy clears every counter.
|
||||||
|
* - FIXED WINDOW, NOT SLIDING. A caller can burst `max` at the very end of one
|
||||||
|
* window and `max` again at the start of the next — up to 2x `max` across a
|
||||||
|
* window boundary. Acceptable here; the goal is to bound runaway cost, not
|
||||||
|
* to meter precisely.
|
||||||
|
*
|
||||||
|
* It exists because the routes it guards spend real money on GPU inference and
|
||||||
|
* shipping them with NO limit at all is worse than shipping an imperfect one.
|
||||||
|
*
|
||||||
|
* REPLACE WITH REDIS (or the platform's rate limiter) BEFORE RUNNING MORE THAN
|
||||||
|
* ONE INSTANCE. The `limit()` signature is deliberately narrow so a Redis-backed
|
||||||
|
* implementation can drop straight in — the only change needed is making it
|
||||||
|
* async at the call sites.
|
||||||
|
*
|
||||||
|
* This is a throttle, not an authorization check. See src/lib/server/session.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
/** Requests counted so far in the current window. */
|
||||||
|
count: number;
|
||||||
|
/** Epoch ms at which the current window ends. */
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = new Map<string, Window>();
|
||||||
|
|
||||||
|
/** Drop expired entries so the map cannot grow without bound. */
|
||||||
|
const SWEEP_INTERVAL_MS = 60_000;
|
||||||
|
let lastSweep = 0;
|
||||||
|
|
||||||
|
function sweep(now: number): void {
|
||||||
|
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
|
||||||
|
lastSweep = now;
|
||||||
|
for (const [key, w] of windows) {
|
||||||
|
if (w.resetAt <= now) windows.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** Seconds until the window resets. Send as `Retry-After` when `ok` is false. */
|
||||||
|
retryAfter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count one request against `key` and report whether it is allowed.
|
||||||
|
*
|
||||||
|
* @param key Caller identity — use `rateLimitKey()` from ./session.
|
||||||
|
* @param max Requests allowed per window.
|
||||||
|
* @param windowMs Window length in ms.
|
||||||
|
*/
|
||||||
|
export function limit(key: string, max: number, windowMs: number): LimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
sweep(now);
|
||||||
|
|
||||||
|
const existing = windows.get(key);
|
||||||
|
if (!existing || existing.resetAt <= now) {
|
||||||
|
windows.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { ok: true, retryAfter: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.count >= max) {
|
||||||
|
return { ok: false, retryAfter: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.count += 1;
|
||||||
|
return { ok: true, retryAfter: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/maintenance helper — clears all counters. */
|
||||||
|
export function resetAllLimits(): void {
|
||||||
|
windows.clear();
|
||||||
|
lastSweep = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* base64 / data-URI helpers shared by the RunPod endpoint wrappers.
|
||||||
|
* Different endpoints name their image field differently and some prefix a
|
||||||
|
* `data:` URI — these helpers normalize both.
|
||||||
|
*
|
||||||
|
* PROVENANCE: a faithful port of
|
||||||
|
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/base64.ts`.
|
||||||
|
* The `pickOutputImage` normalization is intentionally identical so both apps
|
||||||
|
* tolerate the same set of endpoint response shapes.
|
||||||
|
*
|
||||||
|
* SERVER-ONLY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** "data:image/png;base64,XXXX" -> "XXXX" (leaves a bare base64 string untouched). */
|
||||||
|
export function stripDataUri(s: string): string {
|
||||||
|
if (!s.startsWith("data:")) return s;
|
||||||
|
const i = s.indexOf(",");
|
||||||
|
return i === -1 ? s : s.slice(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap a bare base64 string in a data: URI. */
|
||||||
|
export function toDataUri(b64: string, mime: string): string {
|
||||||
|
return `data:${mime};base64,${stripDataUri(b64)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the first image-like base64 string out of a RunPod endpoint's `output`,
|
||||||
|
* regardless of which field name it used. Handles the common shapes:
|
||||||
|
* "iVBOR..." (raw string)
|
||||||
|
* { image: "..." } / { image_png } / { image_base64 }
|
||||||
|
* { images: ["..."] } (array)
|
||||||
|
* { output: { image: "..." } } (nested)
|
||||||
|
*/
|
||||||
|
export function pickOutputImage(output: unknown): string {
|
||||||
|
const s = findImageString(output, false);
|
||||||
|
if (!s) throw new Error("RunPod output did not contain an image.");
|
||||||
|
return stripDataUri(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys whose name implies the value IS the image — any non-empty string is accepted. */
|
||||||
|
const IMAGE_KEYS = ["image_png", "image", "image_base64", "images"] as const;
|
||||||
|
/** Generic wrapper keys — a string here must actually look like image data. */
|
||||||
|
const CONTAINER_KEYS = ["output", "result", "data"] as const;
|
||||||
|
|
||||||
|
/** A base64 image payload is long; a status/id string ("success", "job-abc") is short. */
|
||||||
|
function looksLikeImageData(s: string): boolean {
|
||||||
|
if (s.startsWith("data:image/")) return true;
|
||||||
|
return s.length >= 256 && /^[A-Za-z0-9+/=\s]+$/.test(s.slice(0, 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `strict` is true when we descended through a generic wrapper key (output/result/
|
||||||
|
* data), where a bare string could be a status/id rather than an image — so it must
|
||||||
|
* pass `looksLikeImageData`. Under an explicit image key (or at top level) any
|
||||||
|
* non-empty string is taken as the image.
|
||||||
|
*/
|
||||||
|
function findImageString(value: unknown, strict: boolean, depth = 0): string | undefined {
|
||||||
|
if (depth > 5) return undefined;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (!value) return undefined;
|
||||||
|
return !strict || looksLikeImageData(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const el of value) {
|
||||||
|
const s = findImageString(el, strict, depth + 1);
|
||||||
|
if (s) return s;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const o = value as Record<string, unknown>;
|
||||||
|
for (const key of IMAGE_KEYS) {
|
||||||
|
if (key in o) {
|
||||||
|
const s = findImageString(o[key], false, depth + 1);
|
||||||
|
if (s) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of CONTAINER_KEYS) {
|
||||||
|
if (key in o) {
|
||||||
|
const s = findImageString(o[key], true, depth + 1);
|
||||||
|
if (s) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Low-level RunPod transport. Every model runs as its own RunPod endpoint; this
|
||||||
|
* handles the common request envelope, bearer auth, and both response modes:
|
||||||
|
*
|
||||||
|
* - `/runsync` (preferred): the job runs synchronously and the body already
|
||||||
|
* contains `output` (or IS the output for a custom handler).
|
||||||
|
* - `/run`: returns `{ id, status }`; we poll `/status/{id}` until the job
|
||||||
|
* reaches a terminal state or the time budget (kept under the 60s serverless
|
||||||
|
* function cap) is exhausted.
|
||||||
|
*
|
||||||
|
* RUNPOD_API_KEY is read here so callers never handle the secret directly, and
|
||||||
|
* it is NEVER echoed into an error message. Upstream error bodies are truncated
|
||||||
|
* to 160 chars before being surfaced.
|
||||||
|
*
|
||||||
|
* PROVENANCE: a faithful port of
|
||||||
|
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/client.ts` (same 55s
|
||||||
|
* budget, same polling, same RunpodError.status mapping).
|
||||||
|
*
|
||||||
|
* SERVER-ONLY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RunpodError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status = 502) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RunpodError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunpodCallOpts {
|
||||||
|
/** Human label used in error messages, e.g. "sd-inpaint". */
|
||||||
|
name: string;
|
||||||
|
/** Full endpoint URL from the per-model env var (…/runsync or …/run). */
|
||||||
|
url: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
/** Total budget for the whole call incl. polling. Default 55s (< the 60s cap). */
|
||||||
|
timeoutMs?: number;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunpodEnvelope {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
output?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runpodCall<TOut = unknown>(opts: RunpodCallOpts): Promise<TOut> {
|
||||||
|
const { name, url, input } = opts;
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 55_000;
|
||||||
|
const pollIntervalMs = opts.pollIntervalMs ?? 1500;
|
||||||
|
|
||||||
|
const key = process.env.RUNPOD_API_KEY;
|
||||||
|
if (!key) throw new RunpodError("RUNPOD_API_KEY is not set.", 500);
|
||||||
|
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
const authHeaders = { authorization: `Bearer ${key}` };
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...authHeaders },
|
||||||
|
body: JSON.stringify({ input }),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new RunpodError(`Could not reach RunPod (${name}).`, 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
// Truncated on purpose — never surface a full upstream body to the client.
|
||||||
|
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||||
|
throw new RunpodError(`RunPod ${name} error (${res.status}). ${detail}`.trim(), 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => null)) as RunpodEnvelope | null;
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
throw new RunpodError(`RunPod ${name} returned an invalid response.`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal failure reported in a job envelope.
|
||||||
|
if (data.status === "FAILED" || data.status === "CANCELLED") {
|
||||||
|
throw new RunpodError(`RunPod ${name} job ${data.status.toLowerCase()}.`, 502);
|
||||||
|
}
|
||||||
|
// No job id → this is not an async envelope; the body itself is the output.
|
||||||
|
// Covers custom /runsync handlers that return their result directly, even when
|
||||||
|
// it carries a `status` field (e.g. "success").
|
||||||
|
if (data.id === undefined) {
|
||||||
|
return (data.output !== undefined ? data.output : data) as TOut;
|
||||||
|
}
|
||||||
|
// Job envelope that already carries a completed/inline output.
|
||||||
|
if (data.output !== undefined && (data.status === undefined || data.status === "COMPLETED")) {
|
||||||
|
return data.output as TOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async: poll /status/{id} until COMPLETED / FAILED / the time budget runs out.
|
||||||
|
// Each wait + fetch is clamped to the remaining budget so the whole call stays
|
||||||
|
// under `timeoutMs` (kept below the 60s function cap).
|
||||||
|
const statusUrl = url.replace(/\/run(sync)?(\/?)$/, `/status/${data.id}`);
|
||||||
|
for (;;) {
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining < 500) break; // not enough budget for another round
|
||||||
|
await sleep(Math.min(pollIntervalMs, remaining));
|
||||||
|
const left = deadline - Date.now();
|
||||||
|
if (left <= 0) break;
|
||||||
|
let sres: Response;
|
||||||
|
try {
|
||||||
|
sres = await fetch(statusUrl, {
|
||||||
|
headers: authHeaders,
|
||||||
|
signal: AbortSignal.timeout(Math.min(10_000, Math.max(1000, left))),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue; // transient — keep polling until the deadline
|
||||||
|
}
|
||||||
|
if (!sres.ok) continue;
|
||||||
|
const sdata = (await sres.json().catch(() => null)) as RunpodEnvelope | null;
|
||||||
|
if (!sdata) continue;
|
||||||
|
if (sdata.status === "COMPLETED") return sdata.output as TOut;
|
||||||
|
if (sdata.status === "FAILED" || sdata.status === "CANCELLED") {
|
||||||
|
throw new RunpodError(`RunPod ${name} job ${sdata.status.toLowerCase()}.`, 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RunpodError(
|
||||||
|
`RunPod ${name} timed out (raise the endpoint's speed or use /runsync).`,
|
||||||
|
504,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Typed wrappers for each RunPod endpoint behind the Smart Gallery.
|
||||||
|
* Every function reads its own `RUNPOD_*_URL` env var, sends the exact `input`
|
||||||
|
* contract the model spec documents, and normalizes the response.
|
||||||
|
*
|
||||||
|
* PROVENANCE: a faithful port of
|
||||||
|
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/endpoints.ts` —
|
||||||
|
* identical `normalizeDetections` / `normalizeBox` logic and identical env var
|
||||||
|
* names, so an endpoint deployed for the SDK demo works here unchanged.
|
||||||
|
*
|
||||||
|
* SERVER-ONLY. Missing/invalid URLs throw a RunpodError(500) so an unconfigured
|
||||||
|
* op surfaces as a clear message rather than a crash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { stripDataUri, pickOutputImage } from "./base64";
|
||||||
|
import { RunpodError, runpodCall } from "./client";
|
||||||
|
import type {
|
||||||
|
Img2ImgReq,
|
||||||
|
InpaintReq,
|
||||||
|
RunpodDetection,
|
||||||
|
RunpodImageResult,
|
||||||
|
TiltResult,
|
||||||
|
TranscriptResult,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
function envNum(v: string | undefined, fallback: number): number {
|
||||||
|
// Treat a blank/whitespace env var as unset — Number('') is 0 (finite), which
|
||||||
|
// would otherwise send e.g. strength:0 for `RUNPOD_SD_STRENGTH=`.
|
||||||
|
if (v == null || v.trim() === "") return fallback;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointUrl(envVar: string): string {
|
||||||
|
const url = process.env[envVar];
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) {
|
||||||
|
throw new RunpodError(`${envVar} is not set (or is not an http(s) URL).`, 500);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an image-in/image-out endpoint and normalize the result to base64 PNG. */
|
||||||
|
async function imageOp(
|
||||||
|
name: string,
|
||||||
|
envVar: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<RunpodImageResult> {
|
||||||
|
const output = await runpodCall({ name, url: endpointUrl(envVar), input });
|
||||||
|
return { imageBase64: pickOutputImage(output), mimeType: "image/png" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** #6 Background removal (U²-Net via rembg). model: u2net | u2netp | u2net_human_seg */
|
||||||
|
export function rpRemoveBackground(imageB64: string, model?: string): Promise<RunpodImageResult> {
|
||||||
|
return imageOp("background-removal", "RUNPOD_BG_REMOVE_URL", {
|
||||||
|
task: "remove-bg",
|
||||||
|
image: imageB64,
|
||||||
|
...(model ? { model_name: model } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #7 Real-ESRGAN enhance/upscale (RealESRGAN_x4plus). The caller's scale (from the
|
||||||
|
* op / restore pass) is authoritative — it is not overridden by any env default. */
|
||||||
|
export function rpUpscale(
|
||||||
|
imageB64: string,
|
||||||
|
scale: 2 | 4,
|
||||||
|
faceEnhance = false,
|
||||||
|
): Promise<RunpodImageResult> {
|
||||||
|
return imageOp("upscale", "RUNPOD_UPSCALE_URL", {
|
||||||
|
task: "upscale",
|
||||||
|
image: imageB64,
|
||||||
|
scale,
|
||||||
|
face_enhance: faceEnhance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #8 DDColor B&W → colorize. */
|
||||||
|
export function rpColorize(imageB64: string, inputSize?: number): Promise<RunpodImageResult> {
|
||||||
|
return imageOp("colorize", "RUNPOD_COLORIZE_URL", {
|
||||||
|
image: imageB64,
|
||||||
|
...(inputSize ? { input_size: inputSize } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #9 SD 3.5 masked inpainting (sky fix / eraser / fill). Also #11 outpaint (pre-padded). */
|
||||||
|
export function rpInpaint(p: InpaintReq): Promise<RunpodImageResult> {
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
task: "inpaint",
|
||||||
|
image: p.imageB64,
|
||||||
|
mask: p.maskB64,
|
||||||
|
prompt: p.prompt,
|
||||||
|
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.8),
|
||||||
|
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
|
||||||
|
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
|
||||||
|
};
|
||||||
|
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
|
||||||
|
if (negative) input.negative_prompt = negative;
|
||||||
|
if (p.seed != null) input.seed = p.seed;
|
||||||
|
return imageOp("sd-inpaint", "RUNPOD_SD_INPAINT_URL", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
|
||||||
|
export function rpImg2Img(p: Img2ImgReq): Promise<RunpodImageResult> {
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
task: "img2img",
|
||||||
|
image: p.imageB64,
|
||||||
|
prompt: p.prompt,
|
||||||
|
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.6),
|
||||||
|
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
|
||||||
|
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
|
||||||
|
};
|
||||||
|
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
|
||||||
|
if (negative) input.negative_prompt = negative;
|
||||||
|
if (p.seed != null) input.seed = p.seed;
|
||||||
|
return imageOp("sd-img2img", "RUNPOD_SD_IMG2IMG_URL", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #1 YOLO detection → SDK DetectedObject shape (box as fractions 0..1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rpDetect(
|
||||||
|
imageB64: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Promise<RunpodDetection[]> {
|
||||||
|
const output = await runpodCall<unknown>({
|
||||||
|
name: "yolo-detect",
|
||||||
|
url: endpointUrl("RUNPOD_YOLO_URL"),
|
||||||
|
input: { image: imageB64, task: "detect" },
|
||||||
|
});
|
||||||
|
return normalizeDetections(output, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDetectionArray(output: unknown): unknown[] {
|
||||||
|
if (Array.isArray(output)) return output;
|
||||||
|
if (output && typeof output === "object") {
|
||||||
|
const o = output as Record<string, unknown>;
|
||||||
|
for (const key of ["detections", "predictions", "objects", "results", "boxes"]) {
|
||||||
|
if (Array.isArray(o[key])) return o[key] as unknown[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First non-empty STRING among the args (numbers ignored — a numeric `class` is an index, not a name). */
|
||||||
|
function firstLabel(...vals: unknown[]): string | undefined {
|
||||||
|
for (const v of vals) {
|
||||||
|
if (typeof v === "string" && v.trim()) return v.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetections(output: unknown, width: number, height: number): RunpodDetection[] {
|
||||||
|
const out: RunpodDetection[] = [];
|
||||||
|
for (const raw of extractDetectionArray(output)) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
// Prefer a human-readable name (ultralytics tojson puts the string in `name`
|
||||||
|
// and a numeric index in `class`); fall back to class_<id>. Using firstLabel
|
||||||
|
// (not `??`) also means an explicit empty-string label doesn't get kept + dropped.
|
||||||
|
const classId = o.class_id ?? (typeof o.class === "number" ? o.class : undefined);
|
||||||
|
const label = (
|
||||||
|
firstLabel(o.label, o.name, o.class_name, typeof o.class === "string" ? o.class : undefined) ??
|
||||||
|
(classId != null ? `class_${classId}` : "object")
|
||||||
|
).toLowerCase();
|
||||||
|
const confidence = Number(o.confidence ?? o.score ?? o.conf ?? 0) || 0;
|
||||||
|
const box = normalizeBox(o, width, height);
|
||||||
|
if (box && label) out.push({ label, confidence, box });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: unknown): number | null {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNum4(v: unknown): [number, number, number, number] | null {
|
||||||
|
if (!Array.isArray(v) || v.length < 4) return null;
|
||||||
|
const a = num(v[0]);
|
||||||
|
const b = num(v[1]);
|
||||||
|
const c = num(v[2]);
|
||||||
|
const d = num(v[3]);
|
||||||
|
return a === null || b === null || c === null || d === null ? null : [a, b, c, d];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a detection box to {x, y, width, height} as fractions 0..1 of the
|
||||||
|
* image, from whatever shape the endpoint emits:
|
||||||
|
* - `xyxy: [x1,y1,x2,y2]` (ultralytics) and generic `box: [...]` → corner form
|
||||||
|
* - `xywh: [...]` and COCO `bbox: [x,y,w,h]` → x/y/width/height form
|
||||||
|
* - object `{x1,y1,x2,y2}` / `{left,top,right,bottom}` (ultralytics tojson) → corners
|
||||||
|
* - object `{x,y,width,height}` → x/y/width/height
|
||||||
|
* Pixel values (any component > 1) are divided by the image dims; already-
|
||||||
|
* normalized fractions pass through.
|
||||||
|
*/
|
||||||
|
function normalizeBox(
|
||||||
|
o: Record<string, unknown>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): RunpodDetection["box"] | null {
|
||||||
|
const W = width || 1;
|
||||||
|
const H = height || 1;
|
||||||
|
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||||
|
const frac = (x: number, y: number, w: number, h: number): RunpodDetection["box"] => {
|
||||||
|
if (Math.max(Math.abs(x), Math.abs(y), Math.abs(w), Math.abs(h)) > 1) {
|
||||||
|
x /= W;
|
||||||
|
y /= H;
|
||||||
|
w /= W;
|
||||||
|
h /= H;
|
||||||
|
}
|
||||||
|
return { x: clamp01(x), y: clamp01(y), width: clamp01(w), height: clamp01(h) };
|
||||||
|
};
|
||||||
|
const fromXyxy = (x1: number, y1: number, x2: number, y2: number) =>
|
||||||
|
frac(x1, y1, x2 - x1, y2 - y1);
|
||||||
|
|
||||||
|
// 1. Array boxes, interpreted by which key holds them.
|
||||||
|
const xyxyArr = asNum4(o.xyxy);
|
||||||
|
if (xyxyArr) return fromXyxy(xyxyArr[0], xyxyArr[1], xyxyArr[2], xyxyArr[3]);
|
||||||
|
const xywhArr = asNum4(o.xywh) ?? asNum4(o.bbox); // COCO `bbox` is [x,y,w,h]
|
||||||
|
if (xywhArr) return frac(xywhArr[0], xywhArr[1], xywhArr[2], xywhArr[3]);
|
||||||
|
const boxArr = asNum4(o.box); // generic array box → assume corner form
|
||||||
|
if (boxArr) return fromXyxy(boxArr[0], boxArr[1], boxArr[2], boxArr[3]);
|
||||||
|
|
||||||
|
// 2. Object boxes (either nested under `box` or directly on the detection).
|
||||||
|
const src =
|
||||||
|
o.box && typeof o.box === "object" && !Array.isArray(o.box)
|
||||||
|
? (o.box as Record<string, unknown>)
|
||||||
|
: o;
|
||||||
|
const x1 = num(src.x1 ?? src.left);
|
||||||
|
const y1 = num(src.y1 ?? src.top);
|
||||||
|
const x2 = num(src.x2 ?? src.right);
|
||||||
|
const y2 = num(src.y2 ?? src.bottom);
|
||||||
|
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) return fromXyxy(x1, y1, x2, y2);
|
||||||
|
|
||||||
|
const x = num(src.x);
|
||||||
|
const y = num(src.y);
|
||||||
|
const w = num(src.width);
|
||||||
|
const h = num(src.height);
|
||||||
|
if (x !== null && y !== null && w !== null && h !== null) return frac(x, y, w, h);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio / calibration endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** #2 Camera tilt (DeepSingleImageCalibration). */
|
||||||
|
export async function rpTilt(imageB64: string): Promise<TiltResult> {
|
||||||
|
const o = await runpodCall<Record<string, unknown>>({
|
||||||
|
name: "tilt",
|
||||||
|
url: endpointUrl("RUNPOD_TILT_URL"),
|
||||||
|
input: { image: imageB64 },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
rollDegrees: Number(o.roll_degrees ?? 0) || 0,
|
||||||
|
pitchDegrees: Number(o.pitch_degrees ?? 0) || 0,
|
||||||
|
fovDegrees: Number(o.fov_degrees ?? 0) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #3 Voice-to-text (Parakeet). Audio must be WAV 16kHz mono PCM16. */
|
||||||
|
export async function rpTranscribe(
|
||||||
|
audioB64: string,
|
||||||
|
opts?: { language?: string; timestamps?: boolean; punctuation?: boolean },
|
||||||
|
): Promise<TranscriptResult> {
|
||||||
|
const o = await runpodCall<Record<string, unknown>>({
|
||||||
|
name: "transcribe",
|
||||||
|
url: endpointUrl("RUNPOD_STT_URL"),
|
||||||
|
input: { audio: audioB64, task: "transcribe", ...(opts ?? {}) },
|
||||||
|
});
|
||||||
|
const rawSegments = Array.isArray(o.segments) ? o.segments : [];
|
||||||
|
const segments = rawSegments.map((s) => {
|
||||||
|
const seg = (s ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
text: String(seg.text ?? ""),
|
||||||
|
startSec: Number(seg.start_sec ?? 0) || 0,
|
||||||
|
endSec: Number(seg.end_sec ?? 0) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
transcript: String(o.transcript ?? ""),
|
||||||
|
segments: segments.length ? segments : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #12 Audio noise removal (RNNoise). Audio must be WAV 48kHz mono 16-bit PCM. */
|
||||||
|
export async function rpDenoiseAudio(audioB64: string): Promise<{ audioB64: string }> {
|
||||||
|
const o = await runpodCall<Record<string, unknown>>({
|
||||||
|
name: "audio-denoise",
|
||||||
|
url: endpointUrl("RUNPOD_AUDIO_DENOISE_URL"),
|
||||||
|
input: { audio: audioB64, task: "denoise" },
|
||||||
|
});
|
||||||
|
const a = o.audio ?? o.output ?? "";
|
||||||
|
return { audioB64: stripDataUri(String(a)) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Request/response types for the RunPod endpoints behind the Smart Gallery.
|
||||||
|
*
|
||||||
|
* PROVENANCE: a faithful port of
|
||||||
|
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/types.ts`.
|
||||||
|
* Keep the two in sync — the RunPod endpoints are shared between the standalone
|
||||||
|
* SDK demo and this CRM.
|
||||||
|
*
|
||||||
|
* SERVER-ONLY. Imported by src/app/api/gallery/ai/* route handlers, never by a
|
||||||
|
* client component (which must not see a RunPod URL or key).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Normalized image result returned by every image endpoint (raw base64, no data: prefix). */
|
||||||
|
export interface RunpodImageResult {
|
||||||
|
imageBase64: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #9 SD 3.5 masked inpainting (also #11 outpaint, pre-padded). white in mask = regenerate. */
|
||||||
|
export interface InpaintReq {
|
||||||
|
imageB64: string;
|
||||||
|
maskB64: string;
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
strength?: number;
|
||||||
|
guidanceScale?: number;
|
||||||
|
steps?: number;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
|
||||||
|
export interface Img2ImgReq {
|
||||||
|
imageB64: string;
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
strength?: number;
|
||||||
|
guidanceScale?: number;
|
||||||
|
steps?: number;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1 YOLO construction-material classifier, normalized to the SDK's
|
||||||
|
* `DetectedObject` shape (box as fractions 0..1 of the image).
|
||||||
|
*/
|
||||||
|
export interface RunpodDetection {
|
||||||
|
label: string;
|
||||||
|
confidence: number;
|
||||||
|
box: { x: number; y: number; width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #2 DeepSingleImageCalibration — camera tilt. */
|
||||||
|
export interface TiltResult {
|
||||||
|
rollDegrees: number;
|
||||||
|
pitchDegrees: number;
|
||||||
|
fovDegrees: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #3 Parakeet voice-to-text. */
|
||||||
|
export interface TranscriptSegment {
|
||||||
|
text: string;
|
||||||
|
startSec: number;
|
||||||
|
endSec: number;
|
||||||
|
}
|
||||||
|
export interface TranscriptResult {
|
||||||
|
transcript: string;
|
||||||
|
segments?: TranscriptSegment[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Session gate for the Smart Gallery AI routes.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* TRUST MODEL
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* These routes proxy a paid, rate-limited GPU backend (RunPod) using a secret
|
||||||
|
* held only on this server. An unauthenticated route is therefore not merely an
|
||||||
|
* information-disclosure problem — it is a billable-resource problem: anyone who
|
||||||
|
* can reach the URL can spend the operator's GPU budget, and can use the CRM as
|
||||||
|
* an open relay for arbitrary image/audio processing.
|
||||||
|
*
|
||||||
|
* The upstream SDK demo's routes (apps/web/src/app/api/ai/*) are COMPLETELY
|
||||||
|
* unauthenticated. This module is the fix; every ported route must call it
|
||||||
|
* before doing any work.
|
||||||
|
*
|
||||||
|
* WHO IS TRUSTED
|
||||||
|
* We do not verify a JWT here and we do not hold any signing key. The single
|
||||||
|
* source of truth for "is this caller signed in" is the Shell BFF
|
||||||
|
* (`${BFF_ORIGIN}/api/session/context`), which owns the HttpOnly session cookie.
|
||||||
|
* We forward the caller's raw `cookie` header to it and treat a 200 as proof of
|
||||||
|
* a session. Consequences of that choice, stated explicitly:
|
||||||
|
*
|
||||||
|
* - The BFF is trusted absolutely. If it is compromised or misconfigured to
|
||||||
|
* answer 200 for anonymous callers, these routes are open. BFF_ORIGIN must
|
||||||
|
* therefore only ever point at an origin the operator controls.
|
||||||
|
* - We forward the cookie header verbatim and nothing else. No Authorization
|
||||||
|
* header, no bearer token, and never the RunPod key.
|
||||||
|
* - NOTHING IS CACHED. A cached "yes" would keep a revoked/expired session
|
||||||
|
* alive for the cache lifetime, so every AI request costs one BFF round
|
||||||
|
* trip. That is deliberate: correctness over latency for a spend gate.
|
||||||
|
* - A network failure reaching the BFF returns 503 (fail CLOSED), never 200.
|
||||||
|
* If we cannot prove a session, we do not spend GPU budget.
|
||||||
|
*
|
||||||
|
* DEMO MODE
|
||||||
|
* When the Shell is not configured (`NEXT_PUBLIC_SUPABASE_URL` unset) the CRM
|
||||||
|
* runs on its mock portal and there is no session to check, so we allow the
|
||||||
|
* request and log ONCE at startup-of-first-use. This is the same gate
|
||||||
|
* `isShellConfigured()` uses for mock-vs-real auth elsewhere in the app.
|
||||||
|
* IMPORTANT: never deploy to a public origin with the Shell unconfigured AND a
|
||||||
|
* real RUNPOD_API_KEY present — that combination is an open, billable endpoint.
|
||||||
|
*
|
||||||
|
* WHAT THIS IS NOT
|
||||||
|
* This is authentication only, not authorization. It answers "is there a valid
|
||||||
|
* session", not "may this principal use the gallery". Per-resource policy for
|
||||||
|
* gallery data lives in be-crm behind the `crm.gallery` resource; if these AI
|
||||||
|
* routes ever need the same, check it there rather than re-deriving it here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GallerySession =
|
||||||
|
| { ok: true; principalId?: string }
|
||||||
|
| { ok: false; status: number; error: string };
|
||||||
|
|
||||||
|
/** Mirrors src/lib/appshell.ts — kept local so this stays server-only. */
|
||||||
|
function isShellConfigured(): boolean {
|
||||||
|
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
let demoModeWarned = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the caller's session. Returns `{ ok: true }` (optionally with the
|
||||||
|
* principal id, used to key rate limits) or a ready-to-return failure with the
|
||||||
|
* status and message the route should emit.
|
||||||
|
*/
|
||||||
|
export async function requireGallerySession(req: Request): Promise<GallerySession> {
|
||||||
|
if (!isShellConfigured()) {
|
||||||
|
if (!demoModeWarned) {
|
||||||
|
demoModeWarned = true;
|
||||||
|
console.warn(
|
||||||
|
"[gallery-ai] Shell is not configured (NEXT_PUBLIC_SUPABASE_URL unset) — " +
|
||||||
|
"AI routes are UNAUTHENTICATED in demo mode. Do not expose this deployment publicly " +
|
||||||
|
"while RUNPOD_API_KEY is set.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = req.headers.get("cookie");
|
||||||
|
if (!cookie) return { ok: false, status: 401, error: "Not signed in" };
|
||||||
|
|
||||||
|
const origin = (process.env.BFF_ORIGIN ?? "http://localhost:4000").replace(/\/$/, "");
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${origin}/api/session/context`, {
|
||||||
|
headers: { cookie, accept: "application/json" },
|
||||||
|
// Never cache an auth decision — see the trust-model note above.
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fail closed: we could not prove a session, so we do not spend GPU budget.
|
||||||
|
return { ok: false, status: 503, error: "Session service unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) return { ok: false, status: 401, error: "Not signed in" };
|
||||||
|
if (!res.ok) return { ok: false, status: 503, error: "Session service unavailable" };
|
||||||
|
|
||||||
|
// The principal id is best-effort: it only sharpens the rate-limit key, so a
|
||||||
|
// shape we don't recognize degrades to IP-keyed limiting rather than failing.
|
||||||
|
let principalId: string | undefined;
|
||||||
|
try {
|
||||||
|
const data = (await res.json()) as Record<string, unknown> | null;
|
||||||
|
principalId = pickPrincipalId(data);
|
||||||
|
} catch {
|
||||||
|
/* ignore — see above */
|
||||||
|
}
|
||||||
|
|
||||||
|
return principalId ? { ok: true, principalId } : { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrincipalId(data: Record<string, unknown> | null): string | undefined {
|
||||||
|
if (!data) return undefined;
|
||||||
|
const direct = data.userId ?? data.principalId ?? data.sub ?? data.id;
|
||||||
|
if (typeof direct === "string" && direct) return direct;
|
||||||
|
const user = data.user;
|
||||||
|
if (user && typeof user === "object") {
|
||||||
|
const u = user as Record<string, unknown>;
|
||||||
|
const nested = u.id ?? u.userId ?? u.sub;
|
||||||
|
if (typeof nested === "string" && nested) return nested;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-limit key for a request: the authenticated principal when known,
|
||||||
|
* otherwise the first hop of `x-forwarded-for`.
|
||||||
|
*
|
||||||
|
* NOTE the first hop is client-controlled unless a trusted proxy overwrites the
|
||||||
|
* header. It is good enough to throttle honest clients and casual abuse; it is
|
||||||
|
* NOT a security boundary. The session gate above is the security boundary.
|
||||||
|
*/
|
||||||
|
export function rateLimitKey(req: Request, principalId?: string): string {
|
||||||
|
if (principalId) return `u:${principalId}`;
|
||||||
|
const xff = req.headers.get("x-forwarded-for") ?? "";
|
||||||
|
const first = xff.split(",")[0]?.trim();
|
||||||
|
return `ip:${first || "unknown"}`;
|
||||||
|
}
|
||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# vendor/
|
||||||
|
|
||||||
|
Third-party source vendored into this repo so the app builds from a **single checkout**.
|
||||||
|
|
||||||
|
## `photo-gallery-sdk/`
|
||||||
|
|
||||||
|
`@photo-gallery/sdk` — the Smart Gallery engine. Upstream:
|
||||||
|
`https://git.lynkedup.cloud/KaushikRK99/advance-photo-gallery-web-sdk` (`packages/photo-sdk`).
|
||||||
|
|
||||||
|
**This directory is generated. Do not hand-edit it** — changes belong upstream, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sync:gallery-sdk # re-copies src/ + package.json + README.md from the sibling checkout
|
||||||
|
git add vendor/photo-gallery-sdk && git commit -m "chore: sync @photo-gallery/sdk"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why vendored instead of a registry dependency
|
||||||
|
|
||||||
|
The SDK is not published anywhere yet. A `file:../advance-photo-gallery-web-sdk/...` dependency
|
||||||
|
works on a developer machine that has both repos side by side, but **fails every CI/Vercel build**,
|
||||||
|
because the deploy only ever checks out this repo. Vendoring keeps the dependency graph closed.
|
||||||
|
|
||||||
|
Only `src/`, `package.json` and `README.md` are copied — the package's `exports` point at `src/`
|
||||||
|
and `next.config.ts` lists it in `transpilePackages`, so no build output is needed. Its own
|
||||||
|
`node_modules` is deliberately excluded: without it, the SDK's sources resolve `@types/react` from
|
||||||
|
this repo's React 19 rather than the pnpm workspace's React 18, which is what lets them type-check
|
||||||
|
here at all.
|
||||||
|
|
||||||
|
### Replacing this with a real dependency
|
||||||
|
|
||||||
|
When the SDK is published to the Gitea npm registry (the same one `@insignia/*` uses — Vercel
|
||||||
|
already has `GITEA_TOKEN` for it):
|
||||||
|
|
||||||
|
1. Publish it from the SDK repo under a scope this repo's `.npmrc` maps to the registry.
|
||||||
|
2. In `package.json`, swap `"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk"` for the version range.
|
||||||
|
3. Delete this directory and the `sync:gallery-sdk` script.
|
||||||
|
|
||||||
|
Nothing else changes — no import paths, no config.
|
||||||
Vendored
+288
@@ -0,0 +1,288 @@
|
|||||||
|
# @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.
|
||||||
|
|
||||||
|
## Embedding in a host app
|
||||||
|
|
||||||
|
By default `<PhotoGallery>` behaves like a standalone app: it owns the sidebar, the toolbar, the
|
||||||
|
theme switcher and the global keyboard shortcuts, and it assumes a full-viewport parent. All of
|
||||||
|
that is opt-out, so the gallery can be dropped into an existing product's shell instead.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div style={{ height: 'calc(100vh - 64px)' }}>
|
||||||
|
<PhotoGallery
|
||||||
|
embedded
|
||||||
|
adapter={dataDoorAdapter}
|
||||||
|
currentUser={{ id: user.id, name: user.name, avatarUrl: user.avatar }}
|
||||||
|
shareBaseUrl="https://app.example.com/gallery"
|
||||||
|
chrome={{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: false }}
|
||||||
|
keyboardShortcuts={false}
|
||||||
|
theme={hostTheme}
|
||||||
|
themeTokens={hostTokens}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### New props
|
||||||
|
|
||||||
|
| Prop | Type | Default | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `embedded` | `boolean` | `false` | Lays out at `height: 100%` inside the host container instead of assuming a full-viewport parent, and switches interactive elements to `cursor: pointer`. |
|
||||||
|
| `currentUser` | `GalleryUser` | – | The host's signed-in user (`{ id, name, email?, avatarUrl? }`). Comments are stamped with this identity; the free-text author field disappears. |
|
||||||
|
| `shareBaseUrl` | `string` | `${location.origin}/gallery` | Base URL for generated share links (`?shared=<token>` is appended). |
|
||||||
|
| `chrome` | `Partial<GalleryChrome>` | `DEFAULT_CHROME` | Suppress chrome the host already provides: `{ titlebar, sidebar, toolbar, themeSwitcher }`. |
|
||||||
|
| `hiddenViews` | `ViewId[]` | `[]` | Sidebar rows + Collections cards to hide, e.g. `['screenshots', 'sys:documents']`. A section label drops when all its rows are hidden; if the active view becomes hidden the gallery falls back to `library`. |
|
||||||
|
| `keyboardShortcuts` | `boolean` | `true` | Bind global shortcuts (⌘A, Delete, F, Esc, Enter) to `window`. |
|
||||||
|
| `defaultFullscreen` | `boolean` | `false` | Start maximised over the host page. |
|
||||||
|
| `lockProvider` | `LockProvider` | – | Server-backed, per-user lock for Recently Deleted. Replaces the device-local localStorage hash entirely. |
|
||||||
|
|
||||||
|
`showWindowChrome` still works and maps onto `chrome.titlebar`; an explicit `chrome.titlebar` wins.
|
||||||
|
`DEFAULT_CHROME` is exported (`{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: true }`).
|
||||||
|
|
||||||
|
Turning `chrome.themeSwitcher` off removes the Appearance items from the "More" menu, so a host that
|
||||||
|
owns light/dark cannot be overridden from inside the gallery. The config is now **live**: changing
|
||||||
|
`theme`, `themeTokens`, `currentUser` or `chrome` re-applies without remounting the gallery.
|
||||||
|
|
||||||
|
### Full screen / maximise
|
||||||
|
|
||||||
|
The toolbar carries a maximise/restore button (right-hand group, next to Info). Turning it on adds
|
||||||
|
`apg--fullscreen` to the root element, which goes `position: fixed; inset: 0; z-index: 1400;
|
||||||
|
height: 100dvh; border-radius: 0` — so the gallery fills the viewport no matter what height the host
|
||||||
|
container gave it, and sits above the host's own chrome. Escape leaves full screen, but only once
|
||||||
|
nothing else owns Escape: the lightbox, the photo/video editors, the camera, modals and context menus
|
||||||
|
all get it first, and it still clears an object focus or a selection before it un-maximises. Escape is
|
||||||
|
part of the shortcut set, so `keyboardShortcuts={false}` opts out of that too — the button always works.
|
||||||
|
|
||||||
|
The SDK's own overlays needed no change. They are `position: fixed; inset: 0`, which resolves against
|
||||||
|
the viewport — and in full screen the gallery covers exactly the viewport, so they still land right.
|
||||||
|
`z-index: 1400` opens a stacking context, so their existing 1000–1300 z-indexes now stack *inside* it:
|
||||||
|
above the gallery's content and, through 1400, above the host's chrome.
|
||||||
|
|
||||||
|
Headless control lives on the store: `fullscreen`, `setFullscreen(next)`, `toggleFullscreen()`.
|
||||||
|
`defaultFullscreen` only seeds the initial value; the user's toggle owns it afterwards.
|
||||||
|
|
||||||
|
### Server-backed Recently Deleted lock
|
||||||
|
|
||||||
|
By default the Recently Deleted lock is a password hash in `localStorage` — device-local, and invisible
|
||||||
|
to your backend. Pass a `lockProvider` and the SDK delegates every lock operation to it instead, so the
|
||||||
|
lock belongs to the *user* and follows them across devices. The localStorage path is untouched when no
|
||||||
|
provider is given.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhotoGallery
|
||||||
|
embedded
|
||||||
|
lockProvider={{
|
||||||
|
status: () => call('crm.gallery.lock.status', {}),
|
||||||
|
set: async (password) => { await call('crm.gallery.lock.set', { password }); },
|
||||||
|
verify: async (password) => (await call('crm.gallery.lock.verify', { password })).ok,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LockProvider {
|
||||||
|
status(): Promise<{ hasPassword: boolean }>;
|
||||||
|
set(password: string | null): Promise<void>; // null clears it
|
||||||
|
verify(password: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Drive your UI off `lockConfigured: boolean` (refreshed from `status()` during `init()`, and again if
|
||||||
|
the provider arrives late), not `lock.hash` — that field is meaningless on the provider path. Failures
|
||||||
|
are distinguished: a `verify()` that *resolves false* sets `lockError: 'wrong-password'` ("Incorrect
|
||||||
|
password."), while one that *rejects* sets `lockError: 'unavailable'` ("Couldn't check the password.
|
||||||
|
Please try again.") so the user retries instead of doubting what they typed. `clearLockError()` resets
|
||||||
|
it; `refreshLockStatus()` re-reads `status()` on demand.
|
||||||
|
|
||||||
|
### Theme tokens → CSS variables
|
||||||
|
|
||||||
|
Every value is optional. Tokens ending in `Light`/`Dark` are theme-paired (the matching one is
|
||||||
|
applied for the resolved theme); bare names are theme-independent. Numbers are emitted as `px`.
|
||||||
|
Values are rejected if they contain `url(`, `expression(`, `javascript:` or `<>{}`.
|
||||||
|
|
||||||
|
| Token | CSS variable | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `accent` | `--apg-accent` | Also overrides the `accentColor` prop. |
|
||||||
|
| `accentStrongLight` / `accentStrongDark` | `--apg-accent-strong` | Accent behind white text (AA contrast). |
|
||||||
|
| `accentContrast` | `--apg-accent-contrast` | Foreground on top of the accent. |
|
||||||
|
| `dangerLight` / `dangerDark` | `--apg-danger` | Destructive actions. |
|
||||||
|
| `bgLight` / `bgDark` | `--apg-bg` + `--apg-bg-content` | App / content background. |
|
||||||
|
| `elevatedLight` / `elevatedDark` | `--apg-bg-elevated` | Raised surfaces. |
|
||||||
|
| `cardLight` / `cardDark` | `--apg-card` | Collection cards. |
|
||||||
|
| `cardHoverLight` / `cardHoverDark` | `--apg-card-hover` | Card hover state. |
|
||||||
|
| `sidebarBgLight` / `sidebarBgDark` | `--apg-sidebar-bg` | Sidebar glass (semi-dark always uses the dark value). |
|
||||||
|
| `toolbarBgLight` / `toolbarBgDark` | `--apg-toolbar-bg` | Top toolbar glass. |
|
||||||
|
| `menuBgLight` / `menuBgDark` | `--apg-menu-bg` | Context menus, Info panel, popovers. |
|
||||||
|
| `separatorLight` / `separatorDark` | `--apg-separator` | Hairlines. |
|
||||||
|
| `separatorStrongLight` / `separatorStrongDark` | `--apg-separator-strong` | Input borders, scrollbars. |
|
||||||
|
| `hoverLight` / `hoverDark` | `--apg-hover` | Row / icon-button hover wash. |
|
||||||
|
| `activeLight` / `activeDark` | `--apg-active` | Pressed state. |
|
||||||
|
| `sidebarSelectedLight` / `sidebarSelectedDark` | `--apg-sidebar-selected` | Selected sidebar row. |
|
||||||
|
| `textLight` / `textDark` | `--apg-text` | Primary text. |
|
||||||
|
| `textSecondaryLight` / `textSecondaryDark` | `--apg-text-secondary` | Labels, captions. |
|
||||||
|
| `textTertiaryLight` / `textTertiaryDark` | `--apg-text-tertiary` | Hints, timestamps. |
|
||||||
|
| `glassBorderLight` / `glassBorderDark` | `--apg-glass-border` | Border on glass surfaces. |
|
||||||
|
| `fontFamily` | `--apg-font` | Font stack for the whole gallery. |
|
||||||
|
| `sidebarRadius` | `--apg-sidebar-radius` | px. |
|
||||||
|
| `radiusMenu` | `--apg-radius-menu` | px. |
|
||||||
|
| `shadowSm` | `--apg-shadow-sm` | |
|
||||||
|
| `shadowMdLight` / `shadowMdDark` | `--apg-shadow-md` | Menus, action bar. |
|
||||||
|
| `shadowLgLight` / `shadowLgDark` | `--apg-shadow-lg` | Modals. |
|
||||||
|
| `tileFav` | `--apg-tile-fav` | Favourite heart on a tile (default `#ff3b30`). |
|
||||||
|
| `overlayBg` | `--apg-overlay-bg` | Lightbox backdrop (default `rgba(0,0,0,0.97)`). |
|
||||||
|
| `editorBg` | `--apg-editor-bg` | Editor chrome (default `#161617`). |
|
||||||
|
| `segmentedActive` | `--apg-segmented-active` | Active segmented pill. |
|
||||||
|
| `sidebarWidth` | `--apg-sidebar-w` | px. |
|
||||||
|
| `toolbarHeight` | `--apg-toolbar-h` | px. Also drives `--apg-overlay-top`. |
|
||||||
|
|
||||||
|
The Info panel is positioned at `top: var(--apg-overlay-top, 64px)` — set `--apg-overlay-top` via the
|
||||||
|
`style` prop to push it below the host's own header. Full-screen overlays (lightbox, editor, camera,
|
||||||
|
modals, context menus) stay `position: fixed`, which is correct for a modal over a host app.
|
||||||
|
|
||||||
|
### Incremental storage adapters
|
||||||
|
|
||||||
|
`StorageAdapter` gained two optional members. Both are additive — existing `save`/`putBlob` adapters
|
||||||
|
keep working unchanged.
|
||||||
|
|
||||||
|
- **`applyChanges(changes: StateChanges)`** — when present the store calls this *instead of* `save()`
|
||||||
|
on every (debounced, 400 ms) change, sending only what differs from the last persisted snapshot:
|
||||||
|
`{ upsertMedia?, removeMedia?, upsertAlbums?, removeAlbums?, upsertPeople?, removePeople?,
|
||||||
|
labelAliases?, deletedLabels? }`. Overlapping writes are serialized, and a rejection rolls the
|
||||||
|
snapshot back so the next persist retries the same diff. Only non-system albums are ever persisted.
|
||||||
|
- **`putMedia(id, blob, { name, mime }): Promise<StoredBlob>`** — preferred over `putBlob`. It returns
|
||||||
|
`{ ref, url }`: the store sets `item.src = url` (renderable now, may be a short-lived signed URL)
|
||||||
|
and `item.storageRef = ref` (the durable reference that survives a reload).
|
||||||
|
|
||||||
|
Worked example against a data-door backend:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { StorageAdapter, StateChanges, StoredBlob } from '@photo-gallery/sdk';
|
||||||
|
|
||||||
|
export function createDataDoorAdapter(call: (a: string, p: unknown) => Promise<any>): StorageAdapter {
|
||||||
|
return {
|
||||||
|
name: 'data-door',
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
return await call('crm.gallery.state.load', {});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Kept as a fallback for callers that don't use applyChanges.
|
||||||
|
async save(state) {
|
||||||
|
await call('crm.gallery.state.apply', {
|
||||||
|
upsertMedia: state.media,
|
||||||
|
upsertAlbums: state.albums,
|
||||||
|
upsertPeople: state.people,
|
||||||
|
labelAliases: state.labelAliases,
|
||||||
|
deletedLabels: state.deletedLabels,
|
||||||
|
} satisfies StateChanges);
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyChanges(changes: StateChanges) {
|
||||||
|
await call('crm.gallery.state.apply', changes);
|
||||||
|
},
|
||||||
|
|
||||||
|
async putMedia(id, blob, meta): Promise<StoredBlob> {
|
||||||
|
const { ref, uploadUrl, method, headers } = await call('crm.gallery.media.presignUpload', {
|
||||||
|
mediaId: id,
|
||||||
|
mime: meta.mime,
|
||||||
|
sizeBytes: blob.size,
|
||||||
|
filename: meta.name,
|
||||||
|
});
|
||||||
|
await fetch(uploadUrl, { method, headers, body: blob });
|
||||||
|
const { urls } = await call('crm.gallery.media.presignDownload', { refs: [ref] });
|
||||||
|
return { ref, url: urls[ref] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identity-aware comments
|
||||||
|
|
||||||
|
With `currentUser` set, `addComment(id, text)` stamps `{ authorId, author, authorAvatar }` from the
|
||||||
|
configured user, the Info panel renders the real avatar/name instead of a name input (and stops using
|
||||||
|
the `apg:comment-author` localStorage key), and delete affordances appear only on comments whose
|
||||||
|
`authorId` matches. `deleteComment` is a client-side no-op for someone else's comment — the backend
|
||||||
|
remains the authority. Without `currentUser`, behaviour is exactly as before.
|
||||||
|
|
||||||
|
### Uploader & version identity
|
||||||
|
|
||||||
|
With `currentUser` set, imports (`importFiles`) and camera captures stamp `MediaItem.uploadedBy`
|
||||||
|
(a `GalleryUser`) — a re-import never overwrites an existing value. Saving an edit (`addVersion`) or
|
||||||
|
restoring one (`restoreVersion`) stamps `{ authorId, author, authorAvatar }` onto the `MediaVersion`.
|
||||||
|
The Info panel shows an "Uploaded by" block (avatar + name + muted email) and the author beside each
|
||||||
|
version's timestamp; the Versions & Audit view shows the latest editor. All fields are optional — with
|
||||||
|
no `currentUser`, nothing is stamped and the UI renders exactly as before. `be-crm` still owns
|
||||||
|
`owner_principal_id`; `uploadedBy` is display identity only.
|
||||||
|
|
||||||
|
### Editable caption & notes
|
||||||
|
|
||||||
|
The Info panel's caption and a new multi-line `MediaItem.note` are click-to-edit: they save on blur or
|
||||||
|
Enter (⌘/Ctrl+Enter for the note) via `updateMedia`, so they persist through the adapter. `note` is
|
||||||
|
folded into the search haystack, so notes are searchable.
|
||||||
|
|
||||||
|
### Info panel media & comments
|
||||||
|
|
||||||
|
Video items render a real inline `<video controls>` preview (`.apg-info__thumb--video`) at the top of the
|
||||||
|
panel instead of a frozen poster `<img>`, so a video is playable straight from Info. Comments render as
|
||||||
|
cards (`.apg-comment`) with generous spacing around the "Uploaded by" block, the "Analyzing…" line and
|
||||||
|
between comments, and a divider separates the thread from the compose form.
|
||||||
|
|
||||||
|
### Full-address reverse geocoding
|
||||||
|
|
||||||
|
When a located item's Info panel opens and `location.formatted` isn't cached yet, the SDK reverse-
|
||||||
|
geocodes the coordinates once via free OpenStreetMap Nominatim (`addressdetails=1`), parses the result
|
||||||
|
into `GeoLocation` (`road`, `neighbourhood`, `suburb`, `city`, `county`, `state`, `postcode`,
|
||||||
|
`country`, `countryCode`, `formatted`), and persists it with `updateMedia` so it survives a reload and
|
||||||
|
isn't re-fetched. The panel shows the one-line address plus a City / State / Postcode / Country grid
|
||||||
|
and the raw lat/lng; clicking the address opens the Map. A hardened host CSP must allow
|
||||||
|
`nominatim.openstreetmap.org` in `connect-src`.
|
||||||
|
|
||||||
|
### Floating toolbar
|
||||||
|
|
||||||
|
The top toolbar now floats as a rounded glass bar (margins on all sides, `--apg-radius-lg`,
|
||||||
|
`--apg-shadow-md`) to match the sidebar, in embedded and full-screen alike. It stays in normal flow, so
|
||||||
|
the viewport begins below it and scrolling is unaffected. The wide Years/Months/All-Photos segmented is
|
||||||
|
replaced by a compact **"All Photos ▾"** dropdown (`.apg-scalemenu`, reusing the `.apg-menu` popover
|
||||||
|
with its menu role, checkmarks and arrow-key nav) — this clears the zoom-out (−) button that the
|
||||||
|
segmented used to overlap on narrow toolbars.
|
||||||
|
|
||||||
|
### Map date-range control
|
||||||
|
|
||||||
|
The Map location sheet's filter replaces the two bare From/To date inputs with a single **date range**
|
||||||
|
button (`.apg-daterange`) that opens a popover of quick presets (All dates, Last 7 days, Last 30 days,
|
||||||
|
This year) plus a custom From/To pair. It drives the same underlying from/to state, so the AND-combine
|
||||||
|
with search + object chips, the live "N of M" count, and Clear all keep working.
|
||||||
|
|
||||||
|
Map pins fly-to on click (`flyTo`, never zooming out), carry a per-location count badge and a hover
|
||||||
|
mini-slider strip; the single-photo hover tooltip (`.apg-pin__tip`) is `user-select:none` and edge-clamped
|
||||||
|
— MapView sets `--tip-dx` to slide it back inside the map and adds `.apg-pin__tip--below` to flip it under
|
||||||
|
the pin near the top edge — so it can't overflow or get clipped.
|
||||||
|
|
||||||
|
### Video editor transport
|
||||||
|
|
||||||
|
The video editor applies its rotate / flip / crop transform to the video *frame only*, never to a native
|
||||||
|
`<video controls>` bar (which used to rotate with the frame and look broken). Playback is driven by a custom
|
||||||
|
transport rendered outside the transformed element — play/pause, a scrubber and a time readout
|
||||||
|
(`.apg-vedit__transport` / `.apg-vedit__scrub` / `.apg-vedit__time`).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```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.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../nanoid/bin/nanoid.js" "$@"
|
||||||
|
fi
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.js" %*
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright 2017 Andrey Sitnik <andrey@sitnik.es>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Nano ID
|
||||||
|
|
||||||
|
<img src="https://ai.github.io/nanoid/logo.svg" align="right"
|
||||||
|
alt="Nano ID logo by Anton Lovchikov" width="180" height="94">
|
||||||
|
|
||||||
|
**English** | [日本語](./README.ja.md) | [Русский](./README.ru.md) | [简体中文](./README.zh-CN.md) | [Bahasa Indonesia](./README.id-ID.md) | [한국어](./README.ko.md) | [العربية](./README.ar.md)
|
||||||
|
|
||||||
|
A tiny, secure, URL-friendly, unique string ID generator for JavaScript.
|
||||||
|
|
||||||
|
> “An amazing level of senseless perfectionism,
|
||||||
|
> which is simply impossible not to respect.”
|
||||||
|
|
||||||
|
- **Small.** 118 bytes (minified and brotlied). No dependencies.
|
||||||
|
[Size Limit] controls the size.
|
||||||
|
- **Safe.** It uses hardware random generator. Can be used in clusters.
|
||||||
|
- **Short IDs.** It uses a larger alphabet than UUID (`A-Za-z0-9_-`).
|
||||||
|
So ID size was reduced from 36 to 21 symbols.
|
||||||
|
- **Portable.** Nano ID was ported
|
||||||
|
to over [20 programming languages](./README.md#other-programming-languages).
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
model.id = nanoid() //=> "V1StGXR8_Z5jdHi6B-myT"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="" width="22" height="16" /> Made at <b><a href="https://evilmartians.com/devtools?utm_source=nanoid&utm_campaign=devtools-button&utm_medium=github">Evil Martians</a></b>, product consulting for <b>developer tools</b>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[online tool]: https://gitpod.io/#https://github.com/ai/nanoid/
|
||||||
|
[with Babel]: https://developer.epages.com/blog/coding/how-to-transpile-node-modules-with-babel-and-webpack-in-a-monorepo/
|
||||||
|
[Size Limit]: https://github.com/ai/size-limit
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { customAlphabet, nanoid } from '../index.js'
|
||||||
|
|
||||||
|
function print(msg) {
|
||||||
|
process.stdout.write(msg + '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(msg) {
|
||||||
|
process.stderr.write(msg + '\n')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
||||||
|
let root = dirname(fileURLToPath(import.meta.url))
|
||||||
|
let pkg = JSON.parse(readFileSync(join(root, '..', 'package.json'), 'utf8'))
|
||||||
|
print(pkg.version)
|
||||||
|
process.exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||||
|
print(`Usage
|
||||||
|
$ nanoid [options]
|
||||||
|
|
||||||
|
Options
|
||||||
|
-s, --size Generated ID size
|
||||||
|
-a, --alphabet Alphabet to use
|
||||||
|
-v, --version Show version number
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Examples
|
||||||
|
$ nanoid -s 15
|
||||||
|
S9sBF77U6sDB8Yg
|
||||||
|
|
||||||
|
$ nanoid --size 10 --alphabet abc
|
||||||
|
bcabababca`)
|
||||||
|
process.exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
let alphabet, size
|
||||||
|
for (let i = 2; i < process.argv.length; i++) {
|
||||||
|
let arg = process.argv[i]
|
||||||
|
if (arg === '--size' || arg === '-s') {
|
||||||
|
size = Number(process.argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
if (Number.isNaN(size) || size <= 0) {
|
||||||
|
error('Size must be positive integer')
|
||||||
|
}
|
||||||
|
} else if (arg === '--alphabet' || arg === '-a') {
|
||||||
|
alphabet = process.argv[i + 1]
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
error('Unknown argument ' + arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alphabet) {
|
||||||
|
let customNanoid = customAlphabet(alphabet, size)
|
||||||
|
print(customNanoid())
|
||||||
|
} else {
|
||||||
|
print(nanoid(size))
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { urlAlphabet as scopedUrlAlphabet } from './url-alphabet/index.js'
|
||||||
|
|
||||||
|
export { urlAlphabet } from './url-alphabet/index.js'
|
||||||
|
|
||||||
|
export let random = bytes => crypto.getRandomValues(new Uint8Array(bytes))
|
||||||
|
|
||||||
|
export let customRandom = (alphabet, defaultSize, getRandom) => {
|
||||||
|
let safeByteCutoff = 256 - (256 % alphabet.length)
|
||||||
|
|
||||||
|
if (safeByteCutoff === 256) {
|
||||||
|
let mask = alphabet.length - 1
|
||||||
|
|
||||||
|
return (size = defaultSize) => {
|
||||||
|
if (!size) return ''
|
||||||
|
let id = ''
|
||||||
|
while (true) {
|
||||||
|
let bytes = getRandom(size)
|
||||||
|
let j = size
|
||||||
|
while (j--) {
|
||||||
|
id += alphabet[bytes[j] & mask]
|
||||||
|
if (id.length >= size) return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = Math.ceil((1.6 * 256 * defaultSize) / safeByteCutoff)
|
||||||
|
|
||||||
|
return (size = defaultSize) => {
|
||||||
|
if (!size) return ''
|
||||||
|
let id = ''
|
||||||
|
while (true) {
|
||||||
|
let bytes = getRandom(step)
|
||||||
|
let j = step
|
||||||
|
while (j--) {
|
||||||
|
if (bytes[j] < safeByteCutoff) {
|
||||||
|
id += alphabet[bytes[j] % alphabet.length]
|
||||||
|
if (id.length >= size) return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let customAlphabet = (alphabet, size = 21) =>
|
||||||
|
customRandom(alphabet, size | 0, random)
|
||||||
|
|
||||||
|
export let nanoid = (size = 21) => {
|
||||||
|
let id = ''
|
||||||
|
let bytes = crypto.getRandomValues(new Uint8Array((size |= 0)))
|
||||||
|
while (size--) {
|
||||||
|
id += scopedUrlAlphabet[bytes[size] & 63]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* A tiny, secure, URL-friendly, unique string ID generator for JavaScript
|
||||||
|
* with hardware random generator.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { nanoid } from 'nanoid'
|
||||||
|
* model.id = nanoid() //=> "V1StGXR8_Z5jdHi6B-myT"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate secure URL-friendly unique ID.
|
||||||
|
*
|
||||||
|
* By default, the ID will have 21 symbols to have a collision probability
|
||||||
|
* similar to UUID v4.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { nanoid } from 'nanoid'
|
||||||
|
* model.id = nanoid() //=> "Uakgb_J5m9g-0JDMbcJqL"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param size Size of the ID. The default size is 21.
|
||||||
|
* @typeparam Type The ID type to replace `string` with some opaque type.
|
||||||
|
* @returns A random string.
|
||||||
|
*/
|
||||||
|
export function nanoid<Type extends string>(size?: number): Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate secure unique ID with custom alphabet.
|
||||||
|
*
|
||||||
|
* Alphabet must contain 256 symbols or less. Otherwise, the generator
|
||||||
|
* will not be secure.
|
||||||
|
*
|
||||||
|
* @param alphabet Alphabet used to generate the ID.
|
||||||
|
* @param defaultSize Size of the ID. The default size is 21.
|
||||||
|
* @typeparam Type The ID type to replace `string` with some opaque type.
|
||||||
|
* @returns A random string generator.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { customAlphabet } from 'nanoid'
|
||||||
|
* const nanoid = customAlphabet('0123456789абвгдеё', 5)
|
||||||
|
* nanoid() //=> "8ё56а"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function customAlphabet<Type extends string>(
|
||||||
|
alphabet: string,
|
||||||
|
defaultSize?: number
|
||||||
|
): (size?: number) => Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique ID with custom random generator and alphabet.
|
||||||
|
*
|
||||||
|
* Alphabet must contain 256 symbols or less. Otherwise, the generator
|
||||||
|
* will not be secure.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { customRandom } from 'nanoid'
|
||||||
|
*
|
||||||
|
* const nanoid = customRandom('abcdef', 5, size => {
|
||||||
|
* const random = []
|
||||||
|
* for (let i = 0; i < size; i++) {
|
||||||
|
* random.push(randomByte())
|
||||||
|
* }
|
||||||
|
* return random
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* nanoid() //=> "fbaef"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param alphabet Alphabet used to generate a random string.
|
||||||
|
* @param size Size of the random string.
|
||||||
|
* @param random A random bytes generator.
|
||||||
|
* @typeparam Type The ID type to replace `string` with some opaque type.
|
||||||
|
* @returns A random string generator.
|
||||||
|
*/
|
||||||
|
export function customRandom<Type extends string>(
|
||||||
|
alphabet: string,
|
||||||
|
size: number,
|
||||||
|
random: (bytes: number) => Uint8Array
|
||||||
|
): (size?: number) => Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL safe symbols.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { urlAlphabet } from 'nanoid'
|
||||||
|
* const nanoid = customAlphabet(urlAlphabet, 10)
|
||||||
|
* nanoid() //=> "Uakgb_J5m9"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const urlAlphabet: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an array of random bytes collected from hardware noise.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { customRandom, random } from 'nanoid'
|
||||||
|
* const nanoid = customRandom("abcdef", 5, random)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param bytes Size of the array.
|
||||||
|
* @returns An array of random bytes.
|
||||||
|
*/
|
||||||
|
export function random(bytes: number): Uint8Array
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
import { webcrypto as crypto } from 'node:crypto'
|
||||||
|
|
||||||
|
import { urlAlphabet as scopedUrlAlphabet } from './url-alphabet/index.js'
|
||||||
|
|
||||||
|
export { urlAlphabet } from './url-alphabet/index.js'
|
||||||
|
|
||||||
|
const POOL_SIZE_MULTIPLIER = 128
|
||||||
|
let pool, poolOffset
|
||||||
|
|
||||||
|
function fillPool(bytes) {
|
||||||
|
if (bytes < 0) throw new RangeError('Wrong ID size')
|
||||||
|
try {
|
||||||
|
if (!pool || pool.length < bytes) {
|
||||||
|
pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER)
|
||||||
|
crypto.getRandomValues(pool)
|
||||||
|
poolOffset = 0
|
||||||
|
} else if (poolOffset + bytes > pool.length) {
|
||||||
|
crypto.getRandomValues(pool)
|
||||||
|
poolOffset = 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pool = undefined
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
poolOffset += bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function random(bytes) {
|
||||||
|
fillPool((bytes |= 0))
|
||||||
|
return pool.subarray(poolOffset - bytes, poolOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customRandom(alphabet, defaultSize, getRandom) {
|
||||||
|
let safeByteCutoff = 256 - (256 % alphabet.length)
|
||||||
|
|
||||||
|
if (safeByteCutoff === 256) {
|
||||||
|
let mask = alphabet.length - 1
|
||||||
|
|
||||||
|
return (size = defaultSize) => {
|
||||||
|
if (!size) return ''
|
||||||
|
let id = ''
|
||||||
|
while (true) {
|
||||||
|
let bytes = getRandom(size)
|
||||||
|
let i = size
|
||||||
|
while (i--) {
|
||||||
|
id += alphabet[bytes[i] & mask]
|
||||||
|
if (id.length >= size) return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = Math.ceil((1.6 * 256 * defaultSize) / safeByteCutoff)
|
||||||
|
|
||||||
|
return (size = defaultSize) => {
|
||||||
|
if (!size) return ''
|
||||||
|
let id = ''
|
||||||
|
while (true) {
|
||||||
|
let bytes = getRandom(step)
|
||||||
|
let i = step
|
||||||
|
while (i--) {
|
||||||
|
if (bytes[i] < safeByteCutoff) {
|
||||||
|
id += alphabet[bytes[i] % alphabet.length]
|
||||||
|
if (id.length >= size) return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customAlphabet(alphabet, size = 21) {
|
||||||
|
return customRandom(alphabet, size, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nanoid(size = 21) {
|
||||||
|
fillPool((size |= 0))
|
||||||
|
|
||||||
|
let id = ''
|
||||||
|
for (let i = poolOffset - size; i < poolOffset; i++) {
|
||||||
|
id += scopedUrlAlphabet[pool[i] & 63]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
let a="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";export let nanoid=(e=21)=>{let t="",r=crypto.getRandomValues(new Uint8Array(e|=0));for(;e--;)t+=a[63&r[e]];return t};
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* By default, Nano ID uses hardware random bytes generation for security
|
||||||
|
* and low collision probability. If you are not so concerned with security,
|
||||||
|
* you can use it for environments without hardware random generators.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { nanoid } from 'nanoid/non-secure'
|
||||||
|
* const id = nanoid() //=> "Uakgb_J5m9g-0JDMbcJqLJ"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate URL-friendly unique ID. This method uses the non-secure
|
||||||
|
* predictable random generator with bigger collision probability.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { nanoid } from 'nanoid/non-secure'
|
||||||
|
* model.id = nanoid() //=> "Uakgb_J5m9g-0JDMbcJqL"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param size Size of the ID. The default size is 21.
|
||||||
|
* @typeparam Type The ID type to replace `string` with some opaque type.
|
||||||
|
* @returns A random string.
|
||||||
|
*/
|
||||||
|
export function nanoid<Type extends string>(size?: number): Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique ID based on a custom alphabet.
|
||||||
|
* This method uses the non-secure predictable random generator
|
||||||
|
* with bigger collision probability.
|
||||||
|
*
|
||||||
|
* @param alphabet Alphabet used to generate the ID.
|
||||||
|
* @param defaultSize Size of the ID. The default size is 21.
|
||||||
|
* @typeparam Type The ID type to replace `string` with some opaque type.
|
||||||
|
* @returns A random string generator.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { customAlphabet } from 'nanoid/non-secure'
|
||||||
|
* const nanoid = customAlphabet('0123456789абвгдеё', 5)
|
||||||
|
* model.id = nanoid() //=> "8ё56а"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function customAlphabet<Type extends string>(
|
||||||
|
alphabet: string,
|
||||||
|
defaultSize?: number
|
||||||
|
): (size?: number) => Type
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
let urlAlphabet =
|
||||||
|
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
|
||||||
|
|
||||||
|
export let customAlphabet = (alphabet, defaultSize = 21) => {
|
||||||
|
return (size = defaultSize) => {
|
||||||
|
let id = ''
|
||||||
|
let i = size | 0
|
||||||
|
while (i-- > 0) {
|
||||||
|
id += alphabet[(Math.random() * alphabet.length) | 0]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let nanoid = (size = 21) => {
|
||||||
|
let id = ''
|
||||||
|
let i = size | 0
|
||||||
|
while (i-- > 0) {
|
||||||
|
id += urlAlphabet[(Math.random() * 64) | 0]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "nanoid",
|
||||||
|
"version": "5.1.16",
|
||||||
|
"description": "A tiny (118 bytes), secure URL-friendly unique string ID generator",
|
||||||
|
"keywords": [
|
||||||
|
"id",
|
||||||
|
"random",
|
||||||
|
"url",
|
||||||
|
"uuid"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Andrey Sitnik <andrey@sitnik.es>",
|
||||||
|
"repository": "ai/nanoid",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bin": "./bin/nanoid.js",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"browser": {
|
||||||
|
"./index.js": "./index.browser.js"
|
||||||
|
},
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"react-native": {
|
||||||
|
"./index.js": "./index.browser.js"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"browser": "./index.browser.js",
|
||||||
|
"react-native": "./index.browser.js",
|
||||||
|
"default": "./index.js"
|
||||||
|
},
|
||||||
|
"./non-secure": {
|
||||||
|
"types": "./non-secure/index.d.ts",
|
||||||
|
"default": "./non-secure/index.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export let urlAlphabet =
|
||||||
|
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"_vendored": {
|
||||||
|
"from": "advance-photo-gallery-web-sdk/packages/photo-sdk",
|
||||||
|
"note": "Generated by scripts/sync-gallery-sdk.mjs — do not hand-edit. See vendor/README.md."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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')
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
// Keep only string→string entries — never trust persisted data blindly.
|
||||||
|
const labelAliases: Record<string, string> =
|
||||||
|
parsed.labelAliases && typeof parsed.labelAliases === 'object'
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(parsed.labelAliases).filter(
|
||||||
|
([k, v]) => typeof k === 'string' && typeof v === 'string',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
const deletedLabels: string[] = Array.isArray(parsed.deletedLabels)
|
||||||
|
? (parsed.deletedLabels as unknown[]).filter((l): l is string => typeof l === 'string')
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
media,
|
||||||
|
albums,
|
||||||
|
people: Array.isArray(parsed.people) ? parsed.people : [],
|
||||||
|
labelAliases,
|
||||||
|
deletedLabels,
|
||||||
|
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;
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Album, AlbumId, MediaId, MediaItem, Person, PersonId } from '../types';
|
||||||
|
|
||||||
|
export interface PersistedState {
|
||||||
|
media: MediaItem[];
|
||||||
|
albums: Album[];
|
||||||
|
people: Person[];
|
||||||
|
/**
|
||||||
|
* User's permanent object-tag renames (canonical lowercased detector label →
|
||||||
|
* chosen label). Optional for backward compatibility with pre-rename data.
|
||||||
|
*/
|
||||||
|
labelAliases?: Record<string, string>;
|
||||||
|
deletedLabels?: string[];
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An incremental diff of the library, produced by the store on every persist.
|
||||||
|
*
|
||||||
|
* Only the entities that actually changed since the previous successful persist
|
||||||
|
* are included: an entity appears in `upsert*` when it is new or its object
|
||||||
|
* reference changed, and in `remove*` when it disappeared from state. The label
|
||||||
|
* maps are included only when they differ from the last persisted values.
|
||||||
|
* Every field is optional — an empty object means "nothing changed".
|
||||||
|
*/
|
||||||
|
export interface StateChanges {
|
||||||
|
/** Media items that were created or modified since the last persist. */
|
||||||
|
upsertMedia?: MediaItem[];
|
||||||
|
/** Ids of media items that no longer exist (permanently deleted). */
|
||||||
|
removeMedia?: MediaId[];
|
||||||
|
/** User albums (never system albums) created or modified since the last persist. */
|
||||||
|
upsertAlbums?: Album[];
|
||||||
|
/** Ids of user albums that no longer exist. */
|
||||||
|
removeAlbums?: AlbumId[];
|
||||||
|
/** People/pet clusters created or modified since the last persist. */
|
||||||
|
upsertPeople?: Person[];
|
||||||
|
/** Ids of people/pet clusters that no longer exist. */
|
||||||
|
removePeople?: PersonId[];
|
||||||
|
/** Whole label-alias map — sent only when it changed (shallow compare). */
|
||||||
|
labelAliases?: Record<string, string>;
|
||||||
|
/** Whole deleted-label list — sent only when it changed (shallow compare). */
|
||||||
|
deletedLabels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of a durable byte upload: an adapter-owned ref plus a readable URL. */
|
||||||
|
export interface StoredBlob {
|
||||||
|
/** Durable, adapter-owned reference for the bytes. Survives reloads. */
|
||||||
|
ref: string;
|
||||||
|
/** A URL the browser can render right now (may be a short-lived signed URL). */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage adapter contract. Implement this to back the gallery with anything:
|
||||||
|
* localStorage (default), IndexedDB, a REST/GraphQL API, S3, Postgres, etc.
|
||||||
|
*
|
||||||
|
* 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>;
|
||||||
|
/**
|
||||||
|
* Incremental persistence. When present the store calls this INSTEAD of
|
||||||
|
* `save()` on every (debounced) change, passing only the entities that
|
||||||
|
* differ from the previously persisted snapshot. Implement it when the
|
||||||
|
* backend can apply partial writes — it avoids re-uploading the whole
|
||||||
|
* library on every favourite toggle. If this rejects, the store rolls its
|
||||||
|
* snapshot back so the next persist retries the same changes.
|
||||||
|
*/
|
||||||
|
applyChanges?(changes: StateChanges): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Durable byte storage. When present it is preferred over `putBlob()` for
|
||||||
|
* imports and camera captures: the store sets `item.src = url` (renderable
|
||||||
|
* now) and `item.storageRef = ref` (what survives a reload, so the host can
|
||||||
|
* re-sign the URL later). Falls back to `putBlob()` and finally to an
|
||||||
|
* inlined `data:` URL when absent.
|
||||||
|
*/
|
||||||
|
putMedia?(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob>;
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
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>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcribe recorded speech (base64 WAV, 16 kHz mono PCM16) to text.
|
||||||
|
* Powers voice input for photo annotations / comments.
|
||||||
|
*/
|
||||||
|
transcribeAudio?(audioBase64: string): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Denoise recorded audio (base64 WAV) → cleaned base64 WAV. Useful before
|
||||||
|
* transcription when the recording was made on a noisy site.
|
||||||
|
*/
|
||||||
|
denoiseAudio?(audioBase64: string): Promise<string>;
|
||||||
|
|
||||||
|
/** Estimate camera tilt (roll/pitch/fov, degrees) for auto-straightening. */
|
||||||
|
estimateTilt?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<TiltEstimate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera-tilt estimate (degrees). `rollDegrees` = in-plane rotation to correct. */
|
||||||
|
export interface TiltEstimate {
|
||||||
|
rollDegrees: number;
|
||||||
|
pitchDegrees: number;
|
||||||
|
fovDegrees: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerativeEditOp =
|
||||||
|
| { type: 'remove-background' }
|
||||||
|
| { type: 'replace-sky'; prompt?: string; strength?: number }
|
||||||
|
| { type: 'magic-eraser'; mask: ImageData; strength?: number }
|
||||||
|
| { type: 'generative-fill'; prompt: string; mask: ImageData; strength?: number }
|
||||||
|
| { type: 'restore' }
|
||||||
|
| { type: 'upscale'; factor: 2 | 4 }
|
||||||
|
| { type: 'colorize' }
|
||||||
|
/** Outpaint / expand-canvas: pad the image and generatively fill the new border. */
|
||||||
|
| { type: 'outpaint'; prompt?: string; factor?: number; strength?: number }
|
||||||
|
/**
|
||||||
|
* Free-form natural-language edit instruction. `strength` (0..1) controls how
|
||||||
|
* strongly the edit is applied (subtle → strong); the backend maps it to the
|
||||||
|
* appropriate model knob.
|
||||||
|
*/
|
||||||
|
| { type: 'prompt'; prompt: string; strength?: number };
|
||||||
|
|
||||||
|
/** 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,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { AIProvider } from '../ai/types';
|
||||||
|
import { PET_LABELS } from '../constants';
|
||||||
|
import { resolveLabel } from '../lib/smartAlbums';
|
||||||
|
import { sanitizeOcrText } from '../lib/text';
|
||||||
|
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||||||
|
import type { MediaItem } from '../types';
|
||||||
|
|
||||||
|
const CONCURRENCY = 2;
|
||||||
|
const CONFIDENCE = 0.15;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Analyze each image EXACTLY ONCE: every capability is gated on `!analyzedAt`,
|
||||||
|
// so once an item has been analyzed (and analyzedAt persisted) it is never
|
||||||
|
// re-processed on later reloads — even if an individual result field didn't
|
||||||
|
// round-trip through storage. Fresh uploads (no analyzedAt) run everything.
|
||||||
|
const needsObjects = (m: MediaItem) => !!provider.detectObjects && !m.analyzedAt;
|
||||||
|
const needsFaces = (m: MediaItem) =>
|
||||||
|
!!provider.detectFaces && !m.analyzedAt && m.faces === undefined;
|
||||||
|
const needsOcr = (m: MediaItem) => !!provider.ocr && !m.analyzedAt && m.ocrText === undefined;
|
||||||
|
const needsEmbedding = (m: MediaItem) =>
|
||||||
|
!!provider.embedImage && !m.analyzedAt && 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;
|
||||||
|
// Map each detected label through the user's rename map so future uploads
|
||||||
|
// are stored/grouped under the renamed label instead of a fresh class.
|
||||||
|
const aliases = api.getState().labelAliases;
|
||||||
|
const deleted = api.getState().deletedLabels;
|
||||||
|
patch.objectLabels = [
|
||||||
|
...new Set(
|
||||||
|
objects
|
||||||
|
.filter((o) => o.confidence >= CONFIDENCE)
|
||||||
|
.map((o) => resolveLabel(o.label, aliases))
|
||||||
|
.filter((l) => !deleted.includes(l)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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,61 @@
|
|||||||
|
'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 showSidebar = useGallery((s) => s.config.chrome.sidebar);
|
||||||
|
const showToolbar = useGallery((s) => s.config.chrome.toolbar);
|
||||||
|
const shortcutsEnabled = useGallery((s) => s.config.keyboardShortcuts);
|
||||||
|
const view = useGallery((s) => s.view);
|
||||||
|
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
// If the active view becomes hidden (host toggled `hiddenViews`, or a deep link
|
||||||
|
// landed on one), fall back to the Library instead of rendering a dead view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hiddenViews && hiddenViews.includes(view) && view !== 'library') {
|
||||||
|
api.getState().setView('library');
|
||||||
|
}
|
||||||
|
}, [api, view, hiddenViews]);
|
||||||
|
// The hook is always called (stable hook order); it binds nothing when disabled.
|
||||||
|
useKeyboardShortcuts(shortcutsEnabled);
|
||||||
|
|
||||||
|
// Collapse the sidebar by default on phones; expand on larger screens.
|
||||||
|
const lastMobile = useRef<boolean | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastMobile.current === isMobile) return;
|
||||||
|
lastMobile.current = isMobile;
|
||||||
|
api.getState().setSidebar(!isMobile);
|
||||||
|
}, [isMobile, api]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showSidebar ? <Sidebar /> : null}
|
||||||
|
{showSidebar ? (
|
||||||
|
<div
|
||||||
|
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => api.getState().setSidebar(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="apg-main">
|
||||||
|
{showToolbar ? <TopToolbar /> : null}
|
||||||
|
<div className="apg-viewport">
|
||||||
|
<ViewRouter />
|
||||||
|
<SelectionBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
'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,
|
||||||
|
// Stamp who captured this (display identity) when the host supplies a user.
|
||||||
|
uploadedBy: api.getState().config.currentUser,
|
||||||
|
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
|
||||||
|
});
|
||||||
|
api.getState().addMedia([item]);
|
||||||
|
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,171 @@
|
|||||||
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a context menu is open (it owns Escape while it is). */
|
||||||
|
export function isContextMenuOpen(): boolean {
|
||||||
|
return current !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
}
|
||||||
|
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,839 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, 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 { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from '../lib/audioCapture';
|
||||||
|
import { Icon } from '../icons';
|
||||||
|
import { useAIProvider } from './aiContext';
|
||||||
|
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||||||
|
import type { GalleryUser, GeoLocation, 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">
|
||||||
|
{item.kind === 'video' ? (
|
||||||
|
// Videos get a real, playable preview at the top of the panel — the same
|
||||||
|
// affordance images have, not a frozen poster frame.
|
||||||
|
<video
|
||||||
|
className="apg-info__thumb apg-info__thumb--video"
|
||||||
|
src={item.src}
|
||||||
|
poster={item.poster ?? item.thumbnail}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
|
||||||
|
)}
|
||||||
|
<div className="apg-info__name">{item.name}</div>
|
||||||
|
<div className="apg-info__sub">
|
||||||
|
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editable caption (single line) + note (multi-line) — persisted via updateMedia. */}
|
||||||
|
<EditableField
|
||||||
|
key={`cap-${item.id}`}
|
||||||
|
label="Caption"
|
||||||
|
value={item.caption}
|
||||||
|
placeholder="Add a caption…"
|
||||||
|
onSave={(v) => api.getState().updateMedia(item.id, { caption: v })}
|
||||||
|
/>
|
||||||
|
<EditableField
|
||||||
|
key={`note-${item.id}`}
|
||||||
|
label="Note"
|
||||||
|
value={item.note}
|
||||||
|
placeholder="Add a note…"
|
||||||
|
multiline
|
||||||
|
onSave={(v) => api.getState().updateMedia(item.id, { note: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{item.uploadedBy ? <UploadedBy user={item.uploadedBy} /> : null}
|
||||||
|
|
||||||
|
{item.kind === 'image' && !item.analyzedAt ? (
|
||||||
|
<div className="apg-info__analyzing">
|
||||||
|
<span className="apg-info__spinner" aria-hidden />
|
||||||
|
Analyzing image…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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.exif?.LensModel ? <Row label="Lens" value={String(item.exif.LensModel)} /> : null}
|
||||||
|
{item.exif?.Orientation ? (
|
||||||
|
<Row label="Orientation" value={orientationLabel(item.exif.Orientation)} />
|
||||||
|
) : 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.formatted ??
|
||||||
|
item.location.place ??
|
||||||
|
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MiniMap
|
||||||
|
lat={item.location.lat}
|
||||||
|
lng={item.location.lng}
|
||||||
|
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||||
|
/>
|
||||||
|
<AddressBlock
|
||||||
|
item={item}
|
||||||
|
onOpenMap={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
|
||||||
|
Click the map or address 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>
|
||||||
|
{v.author ? (
|
||||||
|
<span className="apg-version__author">
|
||||||
|
{v.authorAvatar ? (
|
||||||
|
<img className="apg-version__author-avatar" src={v.authorAvatar} alt="" />
|
||||||
|
) : (
|
||||||
|
<span className="apg-version__author-avatar apg-version__author-avatar--initial" aria-hidden>
|
||||||
|
{v.author.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="apg-version__author-name">{v.author}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
|
||||||
|
</button>
|
||||||
|
{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 currentUser = useGallery((s) => s.config.currentUser);
|
||||||
|
const comments = item.comments ?? [];
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
// Without a host-provided user we keep the legacy free-text author field
|
||||||
|
// (remembered in localStorage). With one, identity comes from the host.
|
||||||
|
const [author, setAuthor] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return 'You';
|
||||||
|
return window.localStorage.getItem('apg:comment-author') || 'You';
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = useAIProvider();
|
||||||
|
const canVoice = Boolean(provider?.transcribeAudio);
|
||||||
|
const canDenoise = Boolean(provider?.denoiseAudio);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [denoise, setDenoise] = useState(false);
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState<string | null>(null);
|
||||||
|
const recorderRef = useRef<Recorder | null>(null);
|
||||||
|
|
||||||
|
const startVoice = async () => {
|
||||||
|
setVoiceStatus(null);
|
||||||
|
try {
|
||||||
|
recorderRef.current = await startRecording();
|
||||||
|
setRecording(true);
|
||||||
|
} catch (e) {
|
||||||
|
setVoiceStatus(e instanceof Error ? e.message : 'Microphone unavailable.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopVoice = async () => {
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
recorderRef.current = null;
|
||||||
|
setRecording(false);
|
||||||
|
if (!rec || !provider?.transcribeAudio) return;
|
||||||
|
try {
|
||||||
|
const blob = await rec.stop();
|
||||||
|
let wav16: string;
|
||||||
|
if (denoise && provider.denoiseAudio) {
|
||||||
|
setVoiceStatus('Reducing noise…');
|
||||||
|
const wav48 = await blobToWavBase64(blob, 48000);
|
||||||
|
const cleaned = await provider.denoiseAudio(wav48);
|
||||||
|
wav16 = await blobToWavBase64(wavBase64ToBlob(cleaned), 16000);
|
||||||
|
} else {
|
||||||
|
wav16 = await blobToWavBase64(blob, 16000);
|
||||||
|
}
|
||||||
|
setVoiceStatus('Transcribing…');
|
||||||
|
const spoken = (await provider.transcribeAudio(wav16)).trim();
|
||||||
|
if (spoken) setText((prev) => (prev ? `${prev} ${spoken}` : spoken));
|
||||||
|
setVoiceStatus(null);
|
||||||
|
} catch (e) {
|
||||||
|
setVoiceStatus(e instanceof Error ? e.message : 'Could not transcribe audio.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const post = () => {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
if (currentUser) {
|
||||||
|
// The store stamps authorId/author/avatar from config.currentUser.
|
||||||
|
api.getState().addComment(item.id, t);
|
||||||
|
setText('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = author.trim() || 'You';
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('apg:comment-author', name);
|
||||||
|
} 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) => {
|
||||||
|
// With a configured user, only their OWN comments are deletable
|
||||||
|
// (mirrors the server rule enforced on authorId).
|
||||||
|
const canDelete = !currentUser || c.authorId === currentUser.id;
|
||||||
|
return (
|
||||||
|
<li key={c.id} className="apg-comment">
|
||||||
|
{c.authorAvatar ? (
|
||||||
|
<img className="apg-comment__avatar-img" src={c.authorAvatar} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="apg-comment__avatar" aria-hidden>
|
||||||
|
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="apg-comment__body">
|
||||||
|
<div className="apg-comment__meta">
|
||||||
|
<span className="apg-comment__author">{c.author ?? 'You'}</span>
|
||||||
|
<span className="apg-comment__time">
|
||||||
|
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="apg-comment__text">{c.text}</div>
|
||||||
|
</div>
|
||||||
|
{canDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-comment__delete"
|
||||||
|
aria-label="Delete comment"
|
||||||
|
onClick={() => api.getState().deleteComment(item.id, c.id)}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={13} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="apg-comment-form">
|
||||||
|
{currentUser ? (
|
||||||
|
<div className="apg-comment-form__identity">
|
||||||
|
{currentUser.avatarUrl ? (
|
||||||
|
<img className="apg-comment__avatar-img" src={currentUser.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="apg-comment__avatar" aria-hidden>
|
||||||
|
{(currentUser.name || '?').slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="apg-comment__author">{currentUser.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="apg-comment-form__author"
|
||||||
|
value={author}
|
||||||
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
aria-label="Your name"
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="apg-comment-form__input"
|
||||||
|
value={text}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
{canVoice ? (
|
||||||
|
<div className="apg-voice">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`apg-btn apg-btn--small apg-voice__mic${recording ? ' apg-voice__mic--rec' : ''}`}
|
||||||
|
onClick={recording ? stopVoice : startVoice}
|
||||||
|
aria-label={recording ? 'Stop recording' : 'Record a voice comment'}
|
||||||
|
title={recording ? 'Stop & transcribe' : 'Speak your comment'}
|
||||||
|
>
|
||||||
|
<Icon name={recording ? 'check' : 'mic'} size={14} />
|
||||||
|
{recording ? 'Stop' : 'Speak'}
|
||||||
|
</button>
|
||||||
|
{canDenoise ? (
|
||||||
|
<label
|
||||||
|
className="apg-voice__denoise"
|
||||||
|
title="Clean up background noise before transcribing"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={denoise}
|
||||||
|
onChange={(e) => setDenoise(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Reduce noise
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<span className="apg-voice__status" aria-live="polite">
|
||||||
|
{recording ? '● Listening…' : (voiceStatus ?? '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXIF Orientation codes 1–8 → human text (construction shots are often sideways).
|
||||||
|
const ORIENTATION_LABELS: Record<number, string> = {
|
||||||
|
1: 'Normal',
|
||||||
|
2: 'Mirrored horizontal',
|
||||||
|
3: 'Rotated 180°',
|
||||||
|
4: 'Mirrored vertical',
|
||||||
|
5: 'Mirrored + 90° CCW',
|
||||||
|
6: 'Rotated 90° CW',
|
||||||
|
7: 'Mirrored + 90° CW',
|
||||||
|
8: 'Rotated 90° CCW',
|
||||||
|
};
|
||||||
|
function orientationLabel(v: string | number): string {
|
||||||
|
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||||
|
return ORIENTATION_LABELS[n] ?? String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-instance guards so re-opening a panel (or a second panel) never double-
|
||||||
|
// fetches the same item: ids currently in flight, and ids that already failed
|
||||||
|
// (so the auto-fetch doesn't loop — the user must press Retry).
|
||||||
|
const geocodeInFlight = new Set<string>();
|
||||||
|
const geocodeFailed = new Set<string>();
|
||||||
|
|
||||||
|
/** Shape of Nominatim's `address` object (addressdetails=1) — all fields optional. */
|
||||||
|
interface NominatimAddress {
|
||||||
|
road?: string;
|
||||||
|
neighbourhood?: string;
|
||||||
|
suburb?: string;
|
||||||
|
city?: string;
|
||||||
|
town?: string;
|
||||||
|
village?: string;
|
||||||
|
county?: string;
|
||||||
|
state?: string;
|
||||||
|
postcode?: string;
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse-geocodes an item's GPS coords to a full postal address via free
|
||||||
|
* OpenStreetMap Nominatim, then PERSISTS the parsed fields onto the item
|
||||||
|
* (`updateMedia`) so it saves through the adapter and survives a reload. Runs
|
||||||
|
* automatically the first time a located item's Info panel opens (only when
|
||||||
|
* `location.formatted` isn't already cached); manual Retry on error.
|
||||||
|
*
|
||||||
|
* NOTE: a hardened host CSP must allow `nominatim.openstreetmap.org` in
|
||||||
|
* `connect-src` for this fetch to succeed.
|
||||||
|
*/
|
||||||
|
function AddressBlock({ item, onOpenMap }: { item: MediaItem; onOpenMap: () => void }) {
|
||||||
|
const api = useGalleryStoreApi();
|
||||||
|
const loc = item.location!;
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
|
||||||
|
|
||||||
|
const lookup = useCallback(async () => {
|
||||||
|
// One request per id at a time (etiquette + no double-fetch across panels).
|
||||||
|
if (geocodeInFlight.has(item.id)) return;
|
||||||
|
geocodeInFlight.add(item.id);
|
||||||
|
setStatus('loading');
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${loc.lat}&lon=${loc.lng}&format=json&zoom=18&addressdetails=1`,
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as { display_name?: string; address?: NominatimAddress };
|
||||||
|
const a = data.address ?? {};
|
||||||
|
if (data.display_name) {
|
||||||
|
const parsed: Partial<GeoLocation> = {
|
||||||
|
road: a.road,
|
||||||
|
neighbourhood: a.neighbourhood,
|
||||||
|
suburb: a.suburb,
|
||||||
|
city: a.city ?? a.town ?? a.village,
|
||||||
|
county: a.county,
|
||||||
|
state: a.state,
|
||||||
|
postcode: a.postcode,
|
||||||
|
country: a.country,
|
||||||
|
countryCode: a.country_code ? a.country_code.toUpperCase() : undefined,
|
||||||
|
formatted: data.display_name,
|
||||||
|
};
|
||||||
|
// Persist through the adapter (→ be-crm) so it survives reload + isn't refetched.
|
||||||
|
api.getState().updateMedia(item.id, { location: { ...loc, ...parsed } });
|
||||||
|
geocodeFailed.delete(item.id);
|
||||||
|
setStatus('idle');
|
||||||
|
} else {
|
||||||
|
geocodeFailed.add(item.id);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
geocodeFailed.add(item.id);
|
||||||
|
setStatus('error');
|
||||||
|
} finally {
|
||||||
|
geocodeInFlight.delete(item.id);
|
||||||
|
}
|
||||||
|
}, [api, item.id, loc]);
|
||||||
|
|
||||||
|
// Auto-fetch once when a located item without a cached address opens. The
|
||||||
|
// `!loc.formatted` guard (and the failed-set) prevent a re-fetch loop: once
|
||||||
|
// persisted, the item re-renders WITH `formatted` and this no-ops.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loc.formatted && !geocodeFailed.has(item.id) && !geocodeInFlight.has(item.id)) {
|
||||||
|
void lookup();
|
||||||
|
}
|
||||||
|
}, [item.id, loc.formatted, lookup]);
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
geocodeFailed.delete(item.id);
|
||||||
|
void lookup();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loc.formatted) {
|
||||||
|
const cells: Array<[string, string | undefined]> = [
|
||||||
|
['City', loc.city],
|
||||||
|
['State', loc.state],
|
||||||
|
['Postcode', loc.postcode],
|
||||||
|
['Country', loc.country],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="apg-address">
|
||||||
|
<button type="button" className="apg-address__line" onClick={onOpenMap} title="Open in Map">
|
||||||
|
<Icon name="map" size={14} />
|
||||||
|
<span>{loc.formatted}</span>
|
||||||
|
</button>
|
||||||
|
<dl className="apg-address__grid">
|
||||||
|
{cells
|
||||||
|
.filter(([, v]) => Boolean(v))
|
||||||
|
.map(([k, v]) => (
|
||||||
|
<div key={k} className="apg-address__cell">
|
||||||
|
<dt>{k}</dt>
|
||||||
|
<dd>{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="apg-info__coords">
|
||||||
|
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="apg-address">
|
||||||
|
<div className="apg-info__coords">
|
||||||
|
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||||
|
</div>
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<div className="apg-info__hint" aria-live="polite">
|
||||||
|
Looking up address…
|
||||||
|
</div>
|
||||||
|
) : status === 'error' ? (
|
||||||
|
<button type="button" className="apg-btn apg-btn--small" onClick={retry} style={{ marginTop: 6 }}>
|
||||||
|
Retry address
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Uploaded by" identity block: avatar + name + (muted) email. */
|
||||||
|
function UploadedBy({ user }: { user: GalleryUser }) {
|
||||||
|
return (
|
||||||
|
<div className="apg-info__uploader">
|
||||||
|
<span className="apg-info__uploader-label">Uploaded by</span>
|
||||||
|
<div className="apg-info__uploader-row">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img className="apg-info__uploader-avatar" src={user.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="apg-info__uploader-avatar apg-info__uploader-avatar--initial" aria-hidden>
|
||||||
|
{(user.name || '?').slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="apg-info__uploader-meta">
|
||||||
|
<span className="apg-info__uploader-name">{user.name}</span>
|
||||||
|
{user.email ? <span className="apg-info__uploader-email">{user.email}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-to-edit metadata field (caption / note). Saves on blur or Enter (Cmd/Ctrl+
|
||||||
|
* Enter for the multi-line note) via the supplied `onSave`, which persists through
|
||||||
|
* `updateMedia`. Shows an unobtrusive "edited" hint once a value is present.
|
||||||
|
*/
|
||||||
|
function EditableField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
multiline,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value ?? '');
|
||||||
|
|
||||||
|
// Re-sync the draft when the underlying value changes and we're not editing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setDraft(value ?? '');
|
||||||
|
}, [value, editing]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
setEditing(false);
|
||||||
|
const next = draft.trim();
|
||||||
|
if (next !== (value ?? '').trim()) onSave(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasValue = Boolean((value ?? '').trim());
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
const shared = {
|
||||||
|
className: 'apg-editable__input',
|
||||||
|
value: draft,
|
||||||
|
autoFocus: true,
|
||||||
|
placeholder,
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setDraft(e.target.value),
|
||||||
|
onBlur: commit,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="apg-editable apg-editable--editing">
|
||||||
|
<span className="apg-editable__label">{label}</span>
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
{...shared}
|
||||||
|
rows={3}
|
||||||
|
maxLength={4000}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') commit();
|
||||||
|
else if (e.key === 'Escape') {
|
||||||
|
setDraft(value ?? '');
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
{...shared}
|
||||||
|
maxLength={280}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit();
|
||||||
|
else if (e.key === 'Escape') {
|
||||||
|
setDraft(value ?? '');
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="apg-editable" onClick={() => setEditing(true)}>
|
||||||
|
<span className="apg-editable__label">
|
||||||
|
{label}
|
||||||
|
{hasValue ? <span className="apg-editable__edited">edited</span> : null}
|
||||||
|
<Icon name="pencil" size={12} />
|
||||||
|
</span>
|
||||||
|
<span className={['apg-editable__value', hasValue ? '' : 'apg-editable__value--empty'].filter(Boolean).join(' ')}>
|
||||||
|
{hasValue ? value : placeholder}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,62 @@
|
|||||||
|
'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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a modal is currently mounted. Read by global Escape handling so the
|
||||||
|
* modal (which owns Escape) is never fought over by another handler. */
|
||||||
|
export function isModalOpen(): boolean {
|
||||||
|
return current !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,403 @@
|
|||||||
|
'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_CHROME,
|
||||||
|
DEFAULT_FEATURES,
|
||||||
|
type GalleryConfig,
|
||||||
|
type GalleryFeatures,
|
||||||
|
type GalleryStore,
|
||||||
|
type ThemeTokens,
|
||||||
|
} from '../store/store';
|
||||||
|
import type { Album, GalleryUser, MediaItem, ThemePreference, ViewId } 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. Maps onto `chrome.titlebar`. */
|
||||||
|
showWindowChrome?: boolean;
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* The host app's signed-in user. When set, comments are stamped with this
|
||||||
|
* identity (avatar + name + id) instead of a free-text author field, and only
|
||||||
|
* the user's own comments show a delete affordance.
|
||||||
|
*/
|
||||||
|
currentUser?: GalleryUser;
|
||||||
|
/** Base URL for generated share links (default `${location.origin}/gallery`). */
|
||||||
|
shareBaseUrl?: string;
|
||||||
|
/** Suppress pieces of the gallery's own chrome that the host already provides. */
|
||||||
|
chrome?: Partial<GalleryConfig['chrome']>;
|
||||||
|
/**
|
||||||
|
* Sidebar rows + Collections sections to hide (by ViewId).
|
||||||
|
* e.g. `['screenshots', 'sys:documents']`. Default `[]` (nothing hidden).
|
||||||
|
*/
|
||||||
|
hiddenViews?: ViewId[];
|
||||||
|
/** Bind global keyboard shortcuts to `window` (default true). */
|
||||||
|
keyboardShortcuts?: boolean;
|
||||||
|
/**
|
||||||
|
* Embedded mode: the gallery fills its host container (height 100%) instead of
|
||||||
|
* assuming a full-viewport parent, and interactive elements use `cursor: pointer`.
|
||||||
|
*/
|
||||||
|
embedded?: boolean;
|
||||||
|
/**
|
||||||
|
* Server-backed, per-user lock for the Recently Deleted view. When supplied the
|
||||||
|
* SDK uses this INSTEAD of its device-local localStorage hash.
|
||||||
|
*/
|
||||||
|
lockProvider?: {
|
||||||
|
status(): Promise<{ hasPassword: boolean }>;
|
||||||
|
set(password: string | null): Promise<void>; // null clears it
|
||||||
|
verify(password: string): Promise<boolean>;
|
||||||
|
};
|
||||||
|
/** Start the gallery maximised (default false). */
|
||||||
|
defaultFullscreen?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
onReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoGallery(props: PhotoGalleryProps) {
|
||||||
|
const {
|
||||||
|
photos,
|
||||||
|
albums,
|
||||||
|
adapter,
|
||||||
|
ai,
|
||||||
|
theme = 'system',
|
||||||
|
accentColor,
|
||||||
|
borderRadius,
|
||||||
|
themeTokens,
|
||||||
|
features,
|
||||||
|
showWindowChrome = false,
|
||||||
|
title = 'Photos',
|
||||||
|
currentUser,
|
||||||
|
shareBaseUrl,
|
||||||
|
chrome,
|
||||||
|
hiddenViews,
|
||||||
|
keyboardShortcuts = true,
|
||||||
|
embedded = false,
|
||||||
|
lockProvider,
|
||||||
|
defaultFullscreen = false,
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Destructure chrome to primitives so an inline `chrome={{…}}` literal doesn't
|
||||||
|
// recompute the config on every host render. `showWindowChrome` is the legacy
|
||||||
|
// spelling of `chrome.titlebar`; an explicit `chrome.titlebar` wins.
|
||||||
|
const chromeTitlebar = chrome?.titlebar ?? showWindowChrome;
|
||||||
|
const chromeSidebar = chrome?.sidebar ?? DEFAULT_CHROME.sidebar;
|
||||||
|
const chromeToolbar = chrome?.toolbar ?? DEFAULT_CHROME.toolbar;
|
||||||
|
const chromeThemeSwitcher = chrome?.themeSwitcher ?? DEFAULT_CHROME.themeSwitcher;
|
||||||
|
const userId = currentUser?.id;
|
||||||
|
const userName = currentUser?.name;
|
||||||
|
const userEmail = currentUser?.email;
|
||||||
|
const userAvatar = currentUser?.avatarUrl;
|
||||||
|
// Stable key so an inline `hiddenViews={['screenshots']}` literal doesn't
|
||||||
|
// recompute the config on every host render.
|
||||||
|
const hiddenViewsKey = (hiddenViews ?? []).join(',');
|
||||||
|
|
||||||
|
const config: GalleryConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
features: { ...DEFAULT_FEATURES, ...features },
|
||||||
|
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
|
||||||
|
borderRadius: borderRadius ?? 10,
|
||||||
|
showWindowChrome: chromeTitlebar,
|
||||||
|
title,
|
||||||
|
themeTokens,
|
||||||
|
currentUser:
|
||||||
|
userId !== undefined
|
||||||
|
? { id: userId, name: userName ?? '', email: userEmail, avatarUrl: userAvatar }
|
||||||
|
: undefined,
|
||||||
|
shareBaseUrl,
|
||||||
|
chrome: {
|
||||||
|
titlebar: chromeTitlebar,
|
||||||
|
sidebar: chromeSidebar,
|
||||||
|
toolbar: chromeToolbar,
|
||||||
|
themeSwitcher: chromeThemeSwitcher,
|
||||||
|
},
|
||||||
|
hiddenViews: hiddenViewsKey ? (hiddenViewsKey.split(',') as ViewId[]) : [],
|
||||||
|
keyboardShortcuts,
|
||||||
|
embedded,
|
||||||
|
lockProvider,
|
||||||
|
defaultFullscreen,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
features,
|
||||||
|
accentColor,
|
||||||
|
borderRadius,
|
||||||
|
themeTokens,
|
||||||
|
title,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
userAvatar,
|
||||||
|
shareBaseUrl,
|
||||||
|
chromeTitlebar,
|
||||||
|
chromeSidebar,
|
||||||
|
chromeToolbar,
|
||||||
|
chromeThemeSwitcher,
|
||||||
|
hiddenViewsKey,
|
||||||
|
keyboardShortcuts,
|
||||||
|
embedded,
|
||||||
|
lockProvider,
|
||||||
|
defaultFullscreen,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// Keep the live config in sync with the props. The store is created once, so
|
||||||
|
// without this a host changing theme tokens / user / chrome would have no effect.
|
||||||
|
useEffect(() => {
|
||||||
|
store.getState().setConfig(config);
|
||||||
|
}, [store, config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GalleryStoreContext.Provider value={store}>
|
||||||
|
<GalleryRoot
|
||||||
|
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.chrome.titlebar);
|
||||||
|
const embedded = useGallery((s) => s.config.embedded);
|
||||||
|
const fullscreen = useGallery((s) => s.fullscreen);
|
||||||
|
const aiEnabled = useGallery((s) => s.config.features.ai);
|
||||||
|
const cameraEnabled = useGallery((s) => s.config.features.camera);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
/** Pick the light/dark member of a token pair for the active theme. */
|
||||||
|
const pick = (light: string | undefined, darkValue: string | undefined) =>
|
||||||
|
dark ? darkValue : light;
|
||||||
|
const px = (v: number | undefined, name: string) => {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) tokenVars[name] = `${v}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bg = pick(tokens.bgLight, tokens.bgDark);
|
||||||
|
set(bg, '--apg-bg');
|
||||||
|
set(bg, '--apg-bg-content');
|
||||||
|
set(pick(tokens.elevatedLight, tokens.elevatedDark), '--apg-bg-elevated');
|
||||||
|
// In semi-dark the sidebar is always the dark glass value.
|
||||||
|
set(
|
||||||
|
resolvedTheme === 'semi-dark'
|
||||||
|
? tokens.sidebarBgDark
|
||||||
|
: pick(tokens.sidebarBgLight, tokens.sidebarBgDark),
|
||||||
|
'--apg-sidebar-bg',
|
||||||
|
);
|
||||||
|
set(pick(tokens.textLight, tokens.textDark), '--apg-text');
|
||||||
|
px(tokens.sidebarRadius, '--apg-sidebar-radius');
|
||||||
|
|
||||||
|
// ---- extended token map ----
|
||||||
|
set(pick(tokens.accentStrongLight, tokens.accentStrongDark), '--apg-accent-strong');
|
||||||
|
set(tokens.accentContrast, '--apg-accent-contrast');
|
||||||
|
set(pick(tokens.dangerLight, tokens.dangerDark), '--apg-danger');
|
||||||
|
set(pick(tokens.cardLight, tokens.cardDark), '--apg-card');
|
||||||
|
set(pick(tokens.cardHoverLight, tokens.cardHoverDark), '--apg-card-hover');
|
||||||
|
set(pick(tokens.toolbarBgLight, tokens.toolbarBgDark), '--apg-toolbar-bg');
|
||||||
|
set(pick(tokens.menuBgLight, tokens.menuBgDark), '--apg-menu-bg');
|
||||||
|
set(pick(tokens.separatorLight, tokens.separatorDark), '--apg-separator');
|
||||||
|
set(
|
||||||
|
pick(tokens.separatorStrongLight, tokens.separatorStrongDark),
|
||||||
|
'--apg-separator-strong',
|
||||||
|
);
|
||||||
|
set(pick(tokens.hoverLight, tokens.hoverDark), '--apg-hover');
|
||||||
|
set(pick(tokens.activeLight, tokens.activeDark), '--apg-active');
|
||||||
|
set(
|
||||||
|
pick(tokens.sidebarSelectedLight, tokens.sidebarSelectedDark),
|
||||||
|
'--apg-sidebar-selected',
|
||||||
|
);
|
||||||
|
set(pick(tokens.textSecondaryLight, tokens.textSecondaryDark), '--apg-text-secondary');
|
||||||
|
set(pick(tokens.textTertiaryLight, tokens.textTertiaryDark), '--apg-text-tertiary');
|
||||||
|
set(pick(tokens.glassBorderLight, tokens.glassBorderDark), '--apg-glass-border');
|
||||||
|
set(tokens.fontFamily, '--apg-font');
|
||||||
|
px(tokens.radiusMenu, '--apg-radius-menu');
|
||||||
|
set(tokens.shadowSm, '--apg-shadow-sm');
|
||||||
|
set(pick(tokens.shadowMdLight, tokens.shadowMdDark), '--apg-shadow-md');
|
||||||
|
set(pick(tokens.shadowLgLight, tokens.shadowLgDark), '--apg-shadow-lg');
|
||||||
|
set(tokens.tileFav, '--apg-tile-fav');
|
||||||
|
set(tokens.overlayBg, '--apg-overlay-bg');
|
||||||
|
set(tokens.editorBg, '--apg-editor-bg');
|
||||||
|
set(tokens.segmentedActive, '--apg-segmented-active');
|
||||||
|
px(tokens.sidebarWidth, '--apg-sidebar-w');
|
||||||
|
px(tokens.toolbarHeight, '--apg-toolbar-h');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where full-screen overlays (the Info panel) start. A host with its own header
|
||||||
|
// bar can push this down; default matches the standalone toolbar offset.
|
||||||
|
const overlayTop = tokens?.toolbarHeight ? `${tokens.toolbarHeight + 12}px` : '64px';
|
||||||
|
|
||||||
|
const rootStyle: CSSProperties = {
|
||||||
|
['--apg-accent' as string]: accent,
|
||||||
|
['--apg-radius' as string]: `${radius}px`,
|
||||||
|
['--apg-radius-sm' as string]: `${Math.max(2, Math.round(radius * 0.6))}px`,
|
||||||
|
['--apg-radius-lg' as string]: `${Math.round(radius * 1.4)}px`,
|
||||||
|
['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`,
|
||||||
|
['--apg-overlay-top' as string]: overlayTop,
|
||||||
|
...tokenVars,
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'apg',
|
||||||
|
embedded ? 'apg--embedded' : '',
|
||||||
|
fullscreen ? 'apg--fullscreen' : '',
|
||||||
|
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;
|
||||||
|
}
|
||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
'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 { confirmAction, 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);
|
||||||
|
// Views the host asked to hide (e.g. Screenshots, Documents). A row is dropped
|
||||||
|
// when its `view` is listed; a section label is dropped when every row under it
|
||||||
|
// would be hidden.
|
||||||
|
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
||||||
|
const hidden = new Set<ViewId>(hiddenViews ?? []);
|
||||||
|
const shown = (view: ViewId) => !hidden.has(view);
|
||||||
|
const anyShown = (...views: ViewId[]) => views.some(shown);
|
||||||
|
// Recently Deleted lock state → closed lock when protected & not yet opened.
|
||||||
|
const locked = useGallery((s) => s.lockConfigured && !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.lockConfigured) 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),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Right-click an auto object album → permanently rename its tag (e.g. car → excavator).
|
||||||
|
const objectMenu = (album: Album) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const label = album.id.slice('sys:obj:'.length);
|
||||||
|
openContextMenu(e.clientX, e.clientY, [
|
||||||
|
{
|
||||||
|
label: 'Rename Tag',
|
||||||
|
icon: 'tag',
|
||||||
|
onClick: () =>
|
||||||
|
promptAlbumName(
|
||||||
|
'Rename Tag',
|
||||||
|
album.name,
|
||||||
|
(name) => api.getState().renameLabel(label, name),
|
||||||
|
{ placeholder: 'Tag name' },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete Tag',
|
||||||
|
icon: 'trash',
|
||||||
|
danger: true,
|
||||||
|
onClick: () =>
|
||||||
|
confirmAction({
|
||||||
|
title: 'Delete Tag',
|
||||||
|
message: `Remove the "${album.name}" tag? It's deleted from all photos and won't be created again.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => api.getState().deleteLabel(label),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
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" />
|
||||||
|
|
||||||
|
{anyShown(
|
||||||
|
'favourites',
|
||||||
|
'recently-saved',
|
||||||
|
'map',
|
||||||
|
'videos',
|
||||||
|
'screenshots',
|
||||||
|
'sys:documents',
|
||||||
|
'people',
|
||||||
|
'recently-deleted',
|
||||||
|
) ? (
|
||||||
|
<SectionLabel>Pinned</SectionLabel>
|
||||||
|
) : null}
|
||||||
|
{shown('favourites') ? <Row icon="heart" label="Favourites" view="favourites" /> : null}
|
||||||
|
{shown('recently-saved') ? (
|
||||||
|
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
||||||
|
) : null}
|
||||||
|
{shown('map') ? <Row icon="map" label="Map" view="map" /> : null}
|
||||||
|
{shown('videos') ? <Row icon="video" label="Videos" view="videos" /> : null}
|
||||||
|
{shown('screenshots') ? (
|
||||||
|
<Row icon="screenshot" label="Screenshots" view="screenshots" />
|
||||||
|
) : null}
|
||||||
|
{shown('sys:documents') ? (
|
||||||
|
<Row icon="document" label="Documents" view="sys:documents" />
|
||||||
|
) : null}
|
||||||
|
{shown('people') ? <Row icon="person-circle" label="People" view="people" /> : null}
|
||||||
|
{shown('recently-deleted') ? (
|
||||||
|
<Row
|
||||||
|
icon="trash"
|
||||||
|
label="Recently Deleted"
|
||||||
|
view="recently-deleted"
|
||||||
|
trailing={lockIcon}
|
||||||
|
onTrailingClick={toggleLock}
|
||||||
|
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
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
|
||||||
|
onContextMenu={objectMenu(a)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: 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}
|
||||||
|
|
||||||
|
{anyShown('recently-deleted', 'duplicates', 'versions', 'map') ? (
|
||||||
|
<SectionLabel>Utilities</SectionLabel>
|
||||||
|
) : null}
|
||||||
|
{shown('recently-deleted') ? (
|
||||||
|
<Row
|
||||||
|
icon="trash"
|
||||||
|
label="Recently Deleted"
|
||||||
|
view="recently-deleted"
|
||||||
|
trailing={lockIcon}
|
||||||
|
noActive
|
||||||
|
onTrailingClick={toggleLock}
|
||||||
|
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{shown('duplicates') ? <Row icon="duplicates" label="Duplicates" view="duplicates" /> : null}
|
||||||
|
{shown('versions') ? <Row icon="clock" label="Versions & Audit" view="versions" /> : null}
|
||||||
|
{shown('map') ? <Row icon="map" label="Map" view="map" noActive /> : null}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
'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 SCALE_OPTIONS: Array<{ value: LibraryScale; label: string }> = [
|
||||||
|
{ value: 'years', label: 'Years' },
|
||||||
|
{ value: 'months', label: 'Months' },
|
||||||
|
{ value: 'all', label: 'All Photos' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: Array<{ value: GridFilter; label: string }> = [
|
||||||
|
{ value: 'all', label: 'All Items' },
|
||||||
|
{ value: 'favourites', label: 'Favourites' },
|
||||||
|
{ 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);
|
||||||
|
// A host that owns light/dark must not be overridden from inside the gallery.
|
||||||
|
const showThemeSwitcher = useGallery((s) => s.config.chrome.themeSwitcher);
|
||||||
|
const aiStatus = useGallery((s) => s.aiStatus);
|
||||||
|
const infoOpen = useGallery((s) => s.infoOpen);
|
||||||
|
const fullscreen = useGallery((s) => s.fullscreen);
|
||||||
|
const selectionCount = useGallery((s) => s.selection.size);
|
||||||
|
|
||||||
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||||
|
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Library-scale as a compact dropdown (replaces the wide 3-item segmented that
|
||||||
|
// overlapped the zoom-out button on narrow toolbars). Reuses the `.apg-menu`
|
||||||
|
// popover, which supplies the menu role + checkmarks + arrow-key navigation.
|
||||||
|
const scaleLabel = SCALE_OPTIONS.find((o) => o.value === libraryScale)?.label ?? 'All Photos';
|
||||||
|
const openScaleMenu = (e: React.MouseEvent) => {
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
openContextMenu(
|
||||||
|
r.left,
|
||||||
|
r.bottom + 4,
|
||||||
|
SCALE_OPTIONS.map((o) => ({
|
||||||
|
label: o.label,
|
||||||
|
checked: libraryScale === o.value,
|
||||||
|
onClick: () => api.getState().setLibraryScale(o.value),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMoreMenu = (e: React.MouseEvent) => {
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const state = api.getState();
|
||||||
|
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) }]
|
||||||
|
: []),
|
||||||
|
...(showThemeSwitcher
|
||||||
|
? [
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ type: 'label' as const, label: 'Appearance' },
|
||||||
|
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
|
||||||
|
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
|
||||||
|
{
|
||||||
|
label: 'Semi-Dark (Glass)',
|
||||||
|
icon: 'adjust' as const,
|
||||||
|
checked: theme === 'semi-dark',
|
||||||
|
onClick: () => state.setTheme('semi-dark'),
|
||||||
|
},
|
||||||
|
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{
|
||||||
|
label: state.lockConfigured ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
|
||||||
|
icon: state.lockConfigured ? '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 control (context dependent). Flanked by two flex spacers so it centres on
|
||||||
|
wide toolbars and simply sits in flow (never overlapping the right group) when the
|
||||||
|
toolbar is narrow — e.g. embedded, where the host + gallery sidebars eat the width. */}
|
||||||
|
<div className="apg-toolbar__spacer" />
|
||||||
|
<div className="apg-toolbar__center">
|
||||||
|
{view === 'library' || view === 'search' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-scalemenu"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label={`Library scale: ${scaleLabel}`}
|
||||||
|
onClick={openScaleMenu}
|
||||||
|
>
|
||||||
|
<span className="apg-scalemenu__label">{scaleLabel}</span>
|
||||||
|
<Icon name="chevron-down" size={14} />
|
||||||
|
</button>
|
||||||
|
) : view === 'map' ? (
|
||||||
|
<Segmented<MapMode>
|
||||||
|
value={mapMode}
|
||||||
|
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', fullscreen ? 'apg-iconbtn--on' : ''].join(' ')}
|
||||||
|
aria-label={fullscreen ? 'Exit full screen' : 'Enter full screen'}
|
||||||
|
title={fullscreen ? 'Exit full screen' : 'Enter full screen'}
|
||||||
|
aria-pressed={fullscreen}
|
||||||
|
onClick={() => api.getState().toggleFullscreen()}
|
||||||
|
>
|
||||||
|
<Icon name={fullscreen ? 'minimize' : 'maximize'} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
|
||||||
|
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"
|
||||||
|
// Discoverability only: `searchMedia` already matches objectLabels,
|
||||||
|
// OCR text, tags, captions and place names.
|
||||||
|
placeholder="Search photos, objects, text…"
|
||||||
|
value={searchQuery}
|
||||||
|
onFocus={() => setSearchExpanded(true)}
|
||||||
|
onChange={(e) => {
|
||||||
|
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,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../../icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen brush overlay for masked AI edits (Magic Eraser / Generative Fill).
|
||||||
|
* The user paints over a region; on Apply we emit a binary ImageData mask where
|
||||||
|
* painted pixels are WHITE (regenerate) and everything else is BLACK (keep) —
|
||||||
|
* exactly what the inpaint backend expects (see maskToBase64 / rpInpaint).
|
||||||
|
*
|
||||||
|
* The paint canvas is sized to the image's aspect ratio (long side = BASE), then
|
||||||
|
* scaled with CSS to fit the viewport, so the returned mask lines up with the
|
||||||
|
* photo regardless of screen size.
|
||||||
|
*/
|
||||||
|
const BASE = 640;
|
||||||
|
|
||||||
|
export function MaskBrush({
|
||||||
|
src,
|
||||||
|
aspect,
|
||||||
|
title,
|
||||||
|
onCancel,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
aspect: number; // width / height
|
||||||
|
title: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onApply: (mask: ImageData) => void;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const paintingRef = useRef(false);
|
||||||
|
const lastRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const [brush, setBrush] = useState(48);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const cw = aspect >= 1 ? BASE : Math.round(BASE * aspect);
|
||||||
|
const ch = aspect >= 1 ? Math.round(BASE / aspect) : BASE;
|
||||||
|
|
||||||
|
// Escape cancels.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
|
||||||
|
|
||||||
|
const toCanvas = (e: ReactPointerEvent) => {
|
||||||
|
const c = canvasRef.current;
|
||||||
|
if (!c) return { x: 0, y: 0 };
|
||||||
|
const r = c.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((e.clientX - r.left) / r.width) * c.width,
|
||||||
|
y: ((e.clientY - r.top) / r.height) * c.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const paintTo = (x: number, y: number) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (!c) return;
|
||||||
|
c.fillStyle = 'rgba(255,60,60,0.55)';
|
||||||
|
c.strokeStyle = 'rgba(255,60,60,0.55)';
|
||||||
|
c.lineWidth = brush;
|
||||||
|
c.lineCap = 'round';
|
||||||
|
const last = lastRef.current;
|
||||||
|
if (last) {
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(last.x, last.y);
|
||||||
|
c.lineTo(x, y);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(x, y, brush / 2, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
lastRef.current = { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDown = (e: ReactPointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||||
|
paintingRef.current = true;
|
||||||
|
lastRef.current = null;
|
||||||
|
const p = toCanvas(e);
|
||||||
|
paintTo(p.x, p.y);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
const onMove = (e: ReactPointerEvent) => {
|
||||||
|
if (!paintingRef.current) return;
|
||||||
|
const p = toCanvas(e);
|
||||||
|
paintTo(p.x, p.y);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
paintingRef.current = false;
|
||||||
|
lastRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c && canvasRef.current) c.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||||
|
setDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const c = ctx();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!c || !canvas) return;
|
||||||
|
const painted = c.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
// Binarize: any painted (alpha) pixel → opaque white, else opaque black.
|
||||||
|
const out = new ImageData(canvas.width, canvas.height);
|
||||||
|
for (let i = 0; i < painted.data.length; i += 4) {
|
||||||
|
const on = painted.data[i + 3]! > 10;
|
||||||
|
const v = on ? 255 : 0;
|
||||||
|
out.data[i] = v;
|
||||||
|
out.data[i + 1] = v;
|
||||||
|
out.data[i + 2] = v;
|
||||||
|
out.data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
onApply(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="apg-maskbrush" role="dialog" aria-label={title}>
|
||||||
|
<div className="apg-maskbrush__title">{title}</div>
|
||||||
|
<div className="apg-maskbrush__stage" style={{ aspectRatio: `${cw} / ${ch}` }}>
|
||||||
|
<img className="apg-maskbrush__img" src={src} alt="" draggable={false} />
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={cw}
|
||||||
|
height={ch}
|
||||||
|
className="apg-maskbrush__canvas"
|
||||||
|
onPointerDown={onDown}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerUp={onUp}
|
||||||
|
onPointerLeave={onUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="apg-maskbrush__bar">
|
||||||
|
<label className="apg-maskbrush__brush">
|
||||||
|
Brush
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={12}
|
||||||
|
max={120}
|
||||||
|
step={2}
|
||||||
|
value={brush}
|
||||||
|
onChange={(e) => setBrush(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="apg-btn apg-btn--small" onClick={clear} disabled={!dirty}>
|
||||||
|
<Icon name="trash" size={14} /> Clear
|
||||||
|
</button>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button type="button" className="apg-btn apg-btn--small" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-btn apg-btn--primary apg-btn--small"
|
||||||
|
onClick={apply}
|
||||||
|
disabled={!dirty}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
'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';
|
||||||
|
import { MaskBrush } from './MaskBrush';
|
||||||
|
import { VoiceButton } from './VoiceButton';
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ops whose result varies with the "edit strength" slider (the backend maps it per model). */
|
||||||
|
const STRENGTH_OPS = new Set<GenerativeEditOp['type']>([
|
||||||
|
'prompt',
|
||||||
|
'replace-sky',
|
||||||
|
'magic-eraser',
|
||||||
|
'generative-fill',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 [aiStrength, setAiStrength] = useState(0.5);
|
||||||
|
const [maskMode, setMaskMode] = useState<'magic-eraser' | 'generative-fill' | null>(null);
|
||||||
|
const [tiltBusy, setTiltBusy] = useState(false);
|
||||||
|
const [tiltError, setTiltError] = useState<string | null>(null);
|
||||||
|
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);
|
||||||
|
// Attach the current "edit strength" to ops that support it.
|
||||||
|
const opToRun = STRENGTH_OPS.has(op.type)
|
||||||
|
? ({ ...op, strength: aiStrength } as GenerativeEditOp)
|
||||||
|
: op;
|
||||||
|
const blob = await provider.generativeEdit(item, img, opToRun);
|
||||||
|
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 autoStraighten = async () => {
|
||||||
|
if (!provider?.estimateTilt) return;
|
||||||
|
setTiltBusy(true);
|
||||||
|
setTiltError(null);
|
||||||
|
try {
|
||||||
|
const img = await loadCrossOriginImage(item.src);
|
||||||
|
const t = await provider.estimateTilt(item, img);
|
||||||
|
const roll = Math.max(-45, Math.min(45, Math.round(t.rollDegrees)));
|
||||||
|
setEdits((e) => ({ ...e, straighten: roll }));
|
||||||
|
} catch (err) {
|
||||||
|
setTiltError(err instanceof Error ? err.message : 'Could not estimate tilt.');
|
||||||
|
} finally {
|
||||||
|
setTiltBusy(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…</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}
|
||||||
|
{provider?.estimateTilt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-editor__tab"
|
||||||
|
disabled={tiltBusy}
|
||||||
|
onClick={() => void autoStraighten()}
|
||||||
|
>
|
||||||
|
<Icon name="wand" size={15} />{' '}
|
||||||
|
{tiltBusy ? 'Analyzing tilt…' : 'Auto-straighten (fix camera tilt)'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{tiltError ? (
|
||||||
|
<p style={{ color: '#ff6b6b', fontSize: 12, margin: 0 }}>{tiltError}</p>
|
||||||
|
) : 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>
|
||||||
|
{provider?.transcribeAudio ? (
|
||||||
|
<VoiceButton
|
||||||
|
label="Speak → add text"
|
||||||
|
onText={(t) => {
|
||||||
|
setAnnotations([
|
||||||
|
...annotations,
|
||||||
|
{
|
||||||
|
id: nanoid(8),
|
||||||
|
shape: 'text',
|
||||||
|
color: annColor,
|
||||||
|
strokeWidth: 2,
|
||||||
|
x1: 0.08,
|
||||||
|
y1: 0.08,
|
||||||
|
x2: 0.55,
|
||||||
|
y2: 0.17,
|
||||||
|
text: t,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setAnnTool('select');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<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 run through your configured AI backend. 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 className="apg-slider-row" style={{ marginTop: 6 }}>
|
||||||
|
<div className="apg-slider-row__head">
|
||||||
|
<span>Edit strength</span>
|
||||||
|
<span>{Math.round(aiStrength * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="apg-slider"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={aiStrength}
|
||||||
|
disabled={aiBusy}
|
||||||
|
onChange={(e) => setAiStrength(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#9b9ba1',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Subtle</span>
|
||||||
|
<span>Strong</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(255,255,255,0.1)', margin: '4px 0' }} />
|
||||||
|
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
|
||||||
|
Brush & expand tools — inpaint / outpaint.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-editor__tab"
|
||||||
|
disabled={aiBusy}
|
||||||
|
onClick={() => setMaskMode('magic-eraser')}
|
||||||
|
>
|
||||||
|
<Icon name="wand" size={16} /> Magic Eraser (remove an object)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-editor__tab"
|
||||||
|
disabled={aiBusy}
|
||||||
|
title="Paint an area, then it fills it — type a prompt above to control what appears (optional)"
|
||||||
|
onClick={() => setMaskMode('generative-fill')}
|
||||||
|
>
|
||||||
|
<Icon name="image" size={16} /> Generative Fill (paint + prompt)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-editor__tab"
|
||||||
|
disabled={aiBusy}
|
||||||
|
onClick={() => void runAI({ type: 'outpaint', prompt: aiPrompt.trim() || undefined })}
|
||||||
|
>
|
||||||
|
<Icon name="crop" size={16} /> Expand Image (Outpaint)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{maskMode ? (
|
||||||
|
<MaskBrush
|
||||||
|
src={item.src}
|
||||||
|
aspect={imgAspect}
|
||||||
|
title={
|
||||||
|
maskMode === 'magic-eraser'
|
||||||
|
? 'Paint over what to remove'
|
||||||
|
: 'Paint the area to replace (uses your prompt)'
|
||||||
|
}
|
||||||
|
onCancel={() => setMaskMode(null)}
|
||||||
|
onApply={(mask) => {
|
||||||
|
const m = maskMode;
|
||||||
|
setMaskMode(null);
|
||||||
|
if (m === 'generative-fill') {
|
||||||
|
void runAI({ type: 'generative-fill', prompt: aiPrompt.trim() || 'fill naturally', mask });
|
||||||
|
} else {
|
||||||
|
void runAI({ type: 'magic-eraser', mask });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</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,999 @@
|
|||||||
|
'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 { blobToWavBase64, wavBase64ToBlob } from '../../lib/audioCapture';
|
||||||
|
import { useAIProvider } from '../aiContext';
|
||||||
|
import { VoiceButton } from './VoiceButton';
|
||||||
|
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 [playing, setPlaying] = useState(false);
|
||||||
|
const [selOverlay, setSelOverlay] = useState<string | null>(null);
|
||||||
|
const provider = useAIProvider();
|
||||||
|
const [denoiseBusy, setDenoiseBusy] = useState(false);
|
||||||
|
const [denoiseErr, setDenoiseErr] = 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;
|
||||||
|
};
|
||||||
|
// The preview <video> has no native controls (they would rotate with the frame),
|
||||||
|
// so the custom control bar drives it — keep its play/pause icon in sync here.
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
v.addEventListener('timeupdate', onTime);
|
||||||
|
v.addEventListener('play', onPlay);
|
||||||
|
v.addEventListener('pause', onPause);
|
||||||
|
return () => {
|
||||||
|
v.removeEventListener('timeupdate', onTime);
|
||||||
|
v.removeEventListener('play', onPlay);
|
||||||
|
v.removeEventListener('pause', onPause);
|
||||||
|
};
|
||||||
|
}, [edits.trim, edits.segments, duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 runVideoDenoise = async () => {
|
||||||
|
if (!provider?.denoiseAudio) return;
|
||||||
|
setDenoiseBusy(true);
|
||||||
|
setDenoiseErr(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(item.src);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
// Decode the video's audio track → 48 kHz mono WAV → RunPod denoise → clean WAV.
|
||||||
|
const wav48 = await blobToWavBase64(blob, 48000);
|
||||||
|
const cleaned = await provider.denoiseAudio(wav48);
|
||||||
|
const url = URL.createObjectURL(wavBase64ToBlob(cleaned));
|
||||||
|
update({ audio: { ...edits.audio, denoisedSrc: url } });
|
||||||
|
} catch (e) {
|
||||||
|
setDenoiseErr(
|
||||||
|
e instanceof Error ? e.message : 'Could not clean the audio (keep clips under ~30s).',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDenoiseBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
// Live preview for Crop & Rotate. videoBake applies these on export; without
|
||||||
|
// mirroring them on the preview the Rotate/Flip/Crop buttons look like they do
|
||||||
|
// nothing. Quarter-turn rotations are scaled to fit the frame.
|
||||||
|
const pvRot = (((edits.rotation ?? 0) % 360) + 360) % 360;
|
||||||
|
const pvQuarter = pvRot === 90 || pvRot === 270;
|
||||||
|
const pvAspect = (item.width || 16) / (item.height || 9);
|
||||||
|
const pvFit = pvQuarter ? Math.min(pvAspect, 1 / pvAspect) : 1;
|
||||||
|
const previewTransform =
|
||||||
|
pvRot || edits.flipH || edits.flipV
|
||||||
|
? `rotate(${pvRot}deg) scale(${(edits.flipH ? -1 : 1) * pvFit}, ${(edits.flipV ? -1 : 1) * pvFit})`
|
||||||
|
: undefined;
|
||||||
|
const pvCrop = edits.crop;
|
||||||
|
const previewClip = pvCrop
|
||||||
|
? `inset(${(pvCrop.y * 100).toFixed(3)}% ${((1 - pvCrop.x - pvCrop.width) * 100).toFixed(3)}% ${((1 - pvCrop.y - pvCrop.height) * 100).toFixed(3)}% ${(pvCrop.x * 100).toFixed(3)}%)`
|
||||||
|
: undefined;
|
||||||
|
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}
|
||||||
|
playsInline
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
onClick={() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (v) (v.paused ? v.play() : v.pause());
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
display: 'block',
|
||||||
|
filter: filterCss || undefined,
|
||||||
|
transform: previewTransform,
|
||||||
|
clipPath: previewClip,
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Custom transport — OUTSIDE the transformed <video>, so rotate/flip/crop
|
||||||
|
only affect the frame, never the controls (the old native `controls`
|
||||||
|
bar rotated with the video, which looked broken). */}
|
||||||
|
<div className="apg-vedit__transport">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-iconbtn"
|
||||||
|
aria-label={playing ? 'Pause' : 'Play'}
|
||||||
|
onClick={() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (v) (v.paused ? v.play() : v.pause());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={playing ? 'pause' : 'play'} size={18} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="apg-vedit__scrub"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step={0.01}
|
||||||
|
value={Math.min(playhead, duration || 0)}
|
||||||
|
aria-label="Seek"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (v) v.currentTime = Number(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="apg-vedit__time">
|
||||||
|
{fmt(playhead)} / {fmt(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="apg-editor__panel apg-scroll">
|
||||||
|
<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>
|
||||||
|
<VoiceButton
|
||||||
|
label="Speak → text"
|
||||||
|
onText={(t) => {
|
||||||
|
const cur = sel.text && sel.text !== 'Your text' ? sel.text : '';
|
||||||
|
patchOverlay(sel.id, { text: cur ? `${cur} ${t}` : t });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
{provider?.denoiseAudio ? (
|
||||||
|
edits.audio?.denoisedSrc ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
|
<span style={{ color: '#34c759' }}>✓ Background noise reduced (AI)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-btn apg-btn--small"
|
||||||
|
onClick={() => update({ audio: { ...edits.audio, denoisedSrc: undefined } })}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-btn apg-btn--primary"
|
||||||
|
disabled={denoiseBusy}
|
||||||
|
onClick={() => void runVideoDenoise()}
|
||||||
|
>
|
||||||
|
<Icon name="wand" size={14} />{' '}
|
||||||
|
{denoiseBusy ? 'Cleaning audio…' : 'Reduce background noise (AI)'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{denoiseErr ? (
|
||||||
|
<p style={{ color: '#ff6b6b', fontSize: 12, margin: 0 }}>{denoiseErr}</p>
|
||||||
|
) : null}
|
||||||
|
{!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,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from '../../lib/audioCapture';
|
||||||
|
import { Icon } from '../../icons';
|
||||||
|
import { useAIProvider } from '../aiContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable dictation button: record → (optional AI denoise) → transcribe → onText.
|
||||||
|
* Renders nothing if the AI provider can't transcribe. Used by the image-markup
|
||||||
|
* Text tool, the video Text overlay, and the Info comment box.
|
||||||
|
*/
|
||||||
|
export function VoiceButton({
|
||||||
|
onText,
|
||||||
|
denoise = false,
|
||||||
|
label = 'Speak',
|
||||||
|
size = 14,
|
||||||
|
}: {
|
||||||
|
onText: (text: string) => void;
|
||||||
|
denoise?: boolean;
|
||||||
|
label?: string;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const provider = useAIProvider();
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const recRef = useRef<Recorder | null>(null);
|
||||||
|
|
||||||
|
if (!provider?.transcribeAudio) return null;
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
recRef.current = await startRecording();
|
||||||
|
setRecording(true);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e instanceof Error ? e.message : 'Microphone unavailable.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
const rec = recRef.current;
|
||||||
|
recRef.current = null;
|
||||||
|
setRecording(false);
|
||||||
|
if (!rec || !provider.transcribeAudio) return;
|
||||||
|
try {
|
||||||
|
const blob = await rec.stop();
|
||||||
|
let wav: string;
|
||||||
|
if (denoise && provider.denoiseAudio) {
|
||||||
|
setStatus('Reducing noise…');
|
||||||
|
const w48 = await blobToWavBase64(blob, 48000);
|
||||||
|
const cleaned = await provider.denoiseAudio(w48);
|
||||||
|
wav = await blobToWavBase64(wavBase64ToBlob(cleaned), 16000);
|
||||||
|
} else {
|
||||||
|
wav = await blobToWavBase64(blob, 16000);
|
||||||
|
}
|
||||||
|
setStatus('Transcribing…');
|
||||||
|
const t = (await provider.transcribeAudio(wav)).trim();
|
||||||
|
if (t) onText(t);
|
||||||
|
setStatus(null);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e instanceof Error ? e.message : 'Could not transcribe.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="apg-voice">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`apg-btn apg-btn--small apg-voice__mic${recording ? ' apg-voice__mic--rec' : ''}`}
|
||||||
|
onClick={recording ? stop : start}
|
||||||
|
aria-label={recording ? 'Stop recording' : 'Dictate text'}
|
||||||
|
title={recording ? 'Stop & transcribe' : 'Speak to type'}
|
||||||
|
>
|
||||||
|
<Icon name={recording ? 'check' : 'mic'} size={size} />
|
||||||
|
{recording ? 'Stop' : label}
|
||||||
|
</button>
|
||||||
|
{recording || status ? (
|
||||||
|
<span className="apg-voice__status" aria-live="polite">
|
||||||
|
{recording ? '● Listening…' : status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+418
@@ -0,0 +1,418 @@
|
|||||||
|
'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.lockConfigured);
|
||||||
|
// A server-backed lock reads/writes the host's backend, not this device.
|
||||||
|
const serverBacked = useGallery((s) => Boolean(s.config.lockProvider));
|
||||||
|
const [p1, setP1] = useState('');
|
||||||
|
const [p2, setP2] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
|
||||||
|
if (p1 !== p2) return setErr('Passwords do not match.');
|
||||||
|
setBusy(true);
|
||||||
|
const state = api.getState();
|
||||||
|
state.clearLockError();
|
||||||
|
await state.setLockPassword(p1.trim());
|
||||||
|
setBusy(false);
|
||||||
|
// The store reports an unreachable provider rather than throwing.
|
||||||
|
if (api.getState().lockError === 'unavailable') {
|
||||||
|
setErr("Couldn't reach the server. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
const remove = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const state = api.getState();
|
||||||
|
state.clearLockError();
|
||||||
|
await state.removeLockPassword();
|
||||||
|
setBusy(false);
|
||||||
|
if (api.getState().lockError === 'unavailable') {
|
||||||
|
setErr("Couldn't reach the server. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }}>
|
||||||
|
{serverBacked
|
||||||
|
? hasPw
|
||||||
|
? 'Set a new password, or remove the lock. It applies to your account on every device.'
|
||||||
|
: 'Protect Recently Deleted with a password. It applies to your account on every device.'
|
||||||
|
: hasPw
|
||||||
|
? 'Set a new password, or remove the lock. The password is stored only on this device.'
|
||||||
|
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="apg-modal__input"
|
||||||
|
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)' }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void remove()}
|
||||||
|
>
|
||||||
|
Remove Lock
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="apg-btn" onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-btn apg-btn--primary"
|
||||||
|
disabled={busy}
|
||||||
|
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,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { formatDay } from '../../lib/format';
|
||||||
|
import { groupByTime } from '../../lib/grouping';
|
||||||
|
import { resolveLabel } from '../../lib/smartAlbums';
|
||||||
|
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 { openContextMenu } from '../ContextMenu';
|
||||||
|
import { confirmAction, 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 labelAliases = useGallery((s) => s.labelAliases);
|
||||||
|
const live = liveMedia(media);
|
||||||
|
|
||||||
|
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 objectEntries = [...objectLabelCounts(media, labelAliases).entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 14);
|
||||||
|
|
||||||
|
// Right-click an object card → permanently rename its tag (car → excavator).
|
||||||
|
const renameTag = (label: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openContextMenu(e.clientX, e.clientY, [
|
||||||
|
{
|
||||||
|
label: 'Rename Tag',
|
||||||
|
icon: 'tag',
|
||||||
|
onClick: () =>
|
||||||
|
promptAlbumName('Rename Tag', label, (name) => api.getState().renameLabel(label, name), {
|
||||||
|
placeholder: 'Tag name',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete Tag',
|
||||||
|
icon: 'trash',
|
||||||
|
danger: true,
|
||||||
|
onClick: () =>
|
||||||
|
confirmAction({
|
||||||
|
title: 'Delete Tag',
|
||||||
|
message: `Remove the "${label}" tag? It's deleted from all photos and won't be created again.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => api.getState().deleteLabel(label),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="apg-scroll">
|
||||||
|
<div className="apg-collections">
|
||||||
|
{/* 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)}
|
||||||
|
onContextMenu={renameTag(label)}
|
||||||
|
aria-label={`${count} photos containing ${label}`}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
// Match on the resolved label so the cover works for items still
|
||||||
|
// stored under the original detector label.
|
||||||
|
const cover = live.find((m) =>
|
||||||
|
m.objectLabels.some((l) => resolveLabel(l, labelAliases) === 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}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
</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,161 @@
|
|||||||
|
'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 }) {
|
||||||
|
// Host-neutral copy: the SDK is embedded in other products (see `title`), so the
|
||||||
|
// empty state must not name a specific photo app or reference its settings.
|
||||||
|
const hints: Array<{ icon: IconName; text: string }> = [
|
||||||
|
{ icon: 'download', text: 'Click the + button to import.' },
|
||||||
|
{ icon: 'duplicates', text: 'Drag photos and videos straight in.' },
|
||||||
|
{ icon: 'camera', text: 'Capture a shot with your camera.' },
|
||||||
|
{ icon: 'image', text: 'Everything you add is searchable.' },
|
||||||
|
];
|
||||||
|
const title = useGallery((s) => s.config.title);
|
||||||
|
return (
|
||||||
|
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
|
||||||
|
<div className="apg-empty__title">Welcome to {title}</div>
|
||||||
|
<div className="apg-empty__subtitle">To get started, do any of the following:</div>
|
||||||
|
<div className="apg-empty__hints">
|
||||||
|
{hints.map((h, i) => (
|
||||||
|
<div className="apg-empty__hint" key={i}>
|
||||||
|
<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,733 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { MAP_TILES } from '../../constants';
|
||||||
|
import { groupByTime } from '../../lib/grouping';
|
||||||
|
import { Icon } from '../../icons';
|
||||||
|
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||||
|
import {
|
||||||
|
clusterByLocation,
|
||||||
|
type LocationCluster,
|
||||||
|
liveMedia,
|
||||||
|
locatedMedia,
|
||||||
|
searchMedia,
|
||||||
|
} from '../../store/selectors';
|
||||||
|
import type { MediaItem } from '../../types';
|
||||||
|
import { MediaGrid } from '../MediaGrid';
|
||||||
|
import { MosaicGrid } from './MosaicGrid';
|
||||||
|
|
||||||
|
/** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */
|
||||||
|
const TIP_STRIP_MAX = 5;
|
||||||
|
/** How long each frame of the hover mini-slider stays up. */
|
||||||
|
const TIP_FRAME_MS = 900;
|
||||||
|
|
||||||
|
/** Parse a `<input type="date">` value into a local-midnight epoch, or null. */
|
||||||
|
function parseDateInput(value: string, endOfDay: boolean): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const [y, m, d] = value.split('-').map(Number);
|
||||||
|
if (!y || !m || !d) return null;
|
||||||
|
return endOfDay
|
||||||
|
? new Date(y, m - 1, d, 23, 59, 59, 999).getTime()
|
||||||
|
: new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a Date to a `<input type="date">` value (local `YYYY-MM-DD`). */
|
||||||
|
function toInputDate(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short human label for one `YYYY-MM-DD` value. */
|
||||||
|
function labelDate(value: string): string {
|
||||||
|
const [y, m, d] = value.split('-').map(Number);
|
||||||
|
if (!y || !m || !d) return '';
|
||||||
|
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button label for the current range ("All dates" when unset). */
|
||||||
|
function rangeLabel(from: string, to: string): string {
|
||||||
|
if (!from && !to) return 'All dates';
|
||||||
|
if (from && to) return `${labelDate(from)} – ${labelDate(to)}`;
|
||||||
|
if (from) return `From ${labelDate(from)}`;
|
||||||
|
return `Until ${labelDate(to)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single, polished "date range" control: a button showing the current range
|
||||||
|
* that opens a popover of quick presets + a custom From/To pair. It drives the
|
||||||
|
* SAME from/to strings the MapView already filters by — so search/object chips,
|
||||||
|
* the live count, and Clear all keep working unchanged.
|
||||||
|
*/
|
||||||
|
function DateRangeControl({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
onChange: (from: string, to: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', onDown, true);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', onDown, true);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const daysAgo = (n: number) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(today.getDate() - n);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
const presets: Array<{ label: string; from: string; to: string }> = [
|
||||||
|
{ label: 'All dates', from: '', to: '' },
|
||||||
|
{ label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) },
|
||||||
|
{ label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) },
|
||||||
|
{
|
||||||
|
label: 'This year',
|
||||||
|
from: toInputDate(new Date(today.getFullYear(), 0, 1)),
|
||||||
|
to: toInputDate(today),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const active = Boolean(from || to);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="apg-daterange" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['apg-daterange__button', active ? 'apg-daterange__button--active' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<Icon name="clock" size={14} />
|
||||||
|
<span className="apg-daterange__label">{rangeLabel(from, to)}</span>
|
||||||
|
<Icon name="chevron-down" size={14} />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="apg-daterange__pop" role="dialog" aria-label="Choose a date range">
|
||||||
|
<div className="apg-daterange__presets">
|
||||||
|
{presets.map((p) => {
|
||||||
|
const on = p.from === from && p.to === to;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
className={['apg-daterange__preset', on ? 'apg-daterange__preset--on' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(p.from, p.to);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
{on ? <Icon name="check" size={14} /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="apg-daterange__custom">
|
||||||
|
<div className="apg-daterange__custom-label">Custom range</div>
|
||||||
|
<div className="apg-daterange__fields">
|
||||||
|
<label className="apg-daterange__field">
|
||||||
|
<span>From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value, to)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="apg-daterange__field">
|
||||||
|
<span>To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
|
onChange={(e) => onChange(from, e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapView() {
|
||||||
|
const api = useGalleryStoreApi();
|
||||||
|
const mapMode = useGallery((s) => s.mapMode);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ---- sheet filters (search + date range + object chips), AND-combined ----
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [fromDate, setFromDate] = useState('');
|
||||||
|
const [toDate, setToDate] = useState('');
|
||||||
|
const [objectFilter, setObjectFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setQuery('');
|
||||||
|
setFromDate('');
|
||||||
|
setToDate('');
|
||||||
|
setObjectFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleDown = (e: React.PointerEvent) => {
|
||||||
|
dragRef.current = { startY: e.clientY, startH: sheetH };
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
// Every hover-slider interval currently running, so teardown can kill them all
|
||||||
|
// (a leaked interval keeps mutating detached DOM forever).
|
||||||
|
const tipTimersRef = useRef(new Set<ReturnType<typeof setInterval>>());
|
||||||
|
const clearTipTimers = () => {
|
||||||
|
for (const t of tipTimersRef.current) clearInterval(t);
|
||||||
|
tipTimersRef.current.clear();
|
||||||
|
};
|
||||||
|
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
|
||||||
|
// are created once, so closing over state directly would go stale).
|
||||||
|
const locatedRef = useRef(located);
|
||||||
|
locatedRef.current = located;
|
||||||
|
// Current sheet height in px, read by the fly-to offset (the sheet covers the
|
||||||
|
// bottom of the map, so the pin must end up above it).
|
||||||
|
const sheetHRef = useRef(sheetH);
|
||||||
|
sheetHRef.current = sheetH;
|
||||||
|
|
||||||
|
/** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */
|
||||||
|
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
|
||||||
|
openClusterRef.current = (c) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map) {
|
||||||
|
const current = map.getZoom?.() ?? 2;
|
||||||
|
// Never zoom OUT: a single photo warrants street level, a cluster stays wide
|
||||||
|
// enough that its members remain distinguishable.
|
||||||
|
const target = Math.max(current, c.items.length > 1 ? 12 : 14);
|
||||||
|
const size = map.getSize?.();
|
||||||
|
const mapH: number = size?.y ?? 0;
|
||||||
|
// The sheet is about to occupy the bottom `sheetH` of the map, so shift the
|
||||||
|
// centre up by half of that — the pin then sits in the visible upper band.
|
||||||
|
const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height
|
||||||
|
map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 });
|
||||||
|
if (sheetPx > 0) {
|
||||||
|
// panBy after the fly settles, else Leaflet cancels the in-flight animation.
|
||||||
|
// Leaflet's panBy moves the map pane BY the offset, so content shifts the
|
||||||
|
// opposite way: a POSITIVE y lifts the pin toward the top of the map,
|
||||||
|
// which is the direction that gets it clear of the sheet.
|
||||||
|
map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCluster(c);
|
||||||
|
setSheetH(0.5); // reset to half height each time a pin is opened
|
||||||
|
resetFilters(); // a new pin starts with a clean filter
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opening one photo from a pin's hover strip.
|
||||||
|
const openLightboxRef = useRef<(id: string) => void>(() => {});
|
||||||
|
openLightboxRef.current = (id) => api.getState().openLightbox(id);
|
||||||
|
|
||||||
|
// Create the map once when entering a map tile mode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
|
||||||
|
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) {
|
||||||
|
clearTipTimers();
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
tileRef.current = null;
|
||||||
|
markersRef.current = null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mapMode]);
|
||||||
|
|
||||||
|
// Destroy on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTipTimers();
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
tileRef.current = null;
|
||||||
|
markersRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Every marker is about to be destroyed — kill their hover timers first.
|
||||||
|
clearTipTimers();
|
||||||
|
markersRef.current.clearLayers();
|
||||||
|
|
||||||
|
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
|
||||||
|
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]!;
|
||||||
|
const multi = c.items.length > 1;
|
||||||
|
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
|
||||||
|
// injected; the thumbnail src + count are set via DOM after the marker mounts.
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: 'apg-pin-wrap',
|
||||||
|
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
|
||||||
|
// hover tooltip: one preview for a single photo, an auto-advancing strip
|
||||||
|
// of thumbnails for a cluster.
|
||||||
|
html:
|
||||||
|
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
|
||||||
|
'<span class="apg-pin__count"></span>' +
|
||||||
|
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
|
||||||
|
'<span class="apg-pin__strip"></span>' +
|
||||||
|
'<span class="apg-pin__tip-cap"></span></span></span>',
|
||||||
|
iconSize: [56, 64],
|
||||||
|
iconAnchor: [28, 64],
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null;
|
||||||
|
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
|
||||||
|
|
||||||
|
// Keep the hover tooltip inside the map: on enter, measure it against the map
|
||||||
|
// container and slide it horizontally (--tip-dx) / flip it below (--below) so it
|
||||||
|
// never overflows and gets clipped by the gallery's rounded, overflow-hidden shell.
|
||||||
|
const pinEl = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||||
|
const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null;
|
||||||
|
const clampTip = () => {
|
||||||
|
if (!tipEl) return;
|
||||||
|
const mapEl = el.closest('.leaflet-container') as HTMLElement | null;
|
||||||
|
if (!mapEl) return;
|
||||||
|
tipEl.style.setProperty('--tip-dx', '0px');
|
||||||
|
tipEl.classList.remove('apg-pin__tip--below');
|
||||||
|
const m = mapEl.getBoundingClientRect();
|
||||||
|
const t = tipEl.getBoundingClientRect();
|
||||||
|
const pad = 8;
|
||||||
|
let dx = 0;
|
||||||
|
if (t.left < m.left + pad) dx = m.left + pad - t.left;
|
||||||
|
else if (t.right > m.right - pad) dx = m.right - pad - t.right;
|
||||||
|
if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`);
|
||||||
|
if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below');
|
||||||
|
};
|
||||||
|
pinEl?.addEventListener('mouseenter', clampTip);
|
||||||
|
if (cap) {
|
||||||
|
// textContent (never innerHTML) — place names are attacker-influencable.
|
||||||
|
const place = cover.location?.place ?? '';
|
||||||
|
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) {
|
||||||
|
// The badge always carries the cluster's TOTAL, and only appears when
|
||||||
|
// there is genuinely more than one photo here.
|
||||||
|
badge.textContent = String(c.items.length);
|
||||||
|
badge.style.display = multi ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!multi || !strip) {
|
||||||
|
if (tipImg) tipImg.src = thumb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- multi-photo pin: horizontal strip + auto-advancing preview ----
|
||||||
|
if (tipImg) tipImg.src = thumb;
|
||||||
|
const shown = c.items.slice(0, TIP_STRIP_MAX);
|
||||||
|
const cells: HTMLElement[] = [];
|
||||||
|
for (const item of shown) {
|
||||||
|
const cell = document.createElement('button');
|
||||||
|
cell.type = 'button';
|
||||||
|
cell.className = 'apg-pin__strip-cell';
|
||||||
|
cell.title = item.name;
|
||||||
|
const thumbEl = document.createElement('img');
|
||||||
|
thumbEl.alt = '';
|
||||||
|
thumbEl.src = item.thumbnail ?? item.src;
|
||||||
|
cell.appendChild(thumbEl);
|
||||||
|
// Opening a specific photo must not also open the cluster sheet.
|
||||||
|
cell.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
openLightboxRef.current(item.id);
|
||||||
|
});
|
||||||
|
strip.appendChild(cell);
|
||||||
|
cells.push(cell);
|
||||||
|
}
|
||||||
|
if (c.items.length > TIP_STRIP_MAX) {
|
||||||
|
const more = document.createElement('span');
|
||||||
|
more.className = 'apg-pin__strip-more';
|
||||||
|
more.textContent = `+${c.items.length - TIP_STRIP_MAX}`;
|
||||||
|
strip.appendChild(more);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-advance the big preview (and the highlighted cell) while hovered.
|
||||||
|
const pin = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||||
|
if (!pin) return;
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let frame = 0;
|
||||||
|
const paint = () => {
|
||||||
|
const item = c.items[frame % c.items.length]!;
|
||||||
|
if (tipImg) tipImg.src = item.thumbnail ?? item.src;
|
||||||
|
cells.forEach((cell, i) =>
|
||||||
|
cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
if (timer === null) return;
|
||||||
|
clearInterval(timer);
|
||||||
|
tipTimersRef.current.delete(timer);
|
||||||
|
timer = null;
|
||||||
|
frame = 0;
|
||||||
|
if (tipImg) tipImg.src = thumb;
|
||||||
|
cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on'));
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
if (timer !== null) return;
|
||||||
|
frame = 0;
|
||||||
|
paint();
|
||||||
|
timer = setInterval(() => {
|
||||||
|
frame += 1;
|
||||||
|
paint();
|
||||||
|
}, TIP_FRAME_MS);
|
||||||
|
tipTimersRef.current.add(timer);
|
||||||
|
};
|
||||||
|
pin.addEventListener('mouseenter', start);
|
||||||
|
pin.addEventListener('mouseleave', stop);
|
||||||
|
// Leaflet destroys the element on clearLayers(); `remove` fires first.
|
||||||
|
marker.on('remove', stop);
|
||||||
|
});
|
||||||
|
marker.on('click', () => openClusterRef.current(c));
|
||||||
|
markersRef.current.addLayer(marker);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// Distinct object labels present in the open cluster, most common first.
|
||||||
|
const clusterObjects = useMemo(() => {
|
||||||
|
if (!cluster) return [] as Array<{ label: string; count: number }>;
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const m of cluster.items) {
|
||||||
|
for (const label of new Set(m.objectLabels)) {
|
||||||
|
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([label, count]) => ({ label, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||||
|
}, [cluster]);
|
||||||
|
|
||||||
|
// Search + date range + object chip, combined with AND.
|
||||||
|
const filtered = useMemo<MediaItem[]>(() => {
|
||||||
|
if (!cluster) return [];
|
||||||
|
let items = cluster.items;
|
||||||
|
if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter));
|
||||||
|
const from = parseDateInput(fromDate, false);
|
||||||
|
const to = parseDateInput(toDate, true);
|
||||||
|
if (from !== null) items = items.filter((m) => m.takenAt >= from);
|
||||||
|
if (to !== null) items = items.filter((m) => m.takenAt <= to);
|
||||||
|
// Same matcher as the global search bar, so "car" means the same thing here.
|
||||||
|
if (query.trim()) items = searchMedia(items, query);
|
||||||
|
return items;
|
||||||
|
}, [cluster, query, fromDate, toDate, objectFilter]);
|
||||||
|
|
||||||
|
const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter);
|
||||||
|
|
||||||
|
if (mapMode === 'grid') {
|
||||||
|
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
|
||||||
|
// located ones) so it's a rich date-grouped collage, per the design.
|
||||||
|
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 }} aria-live="polite">
|
||||||
|
{filterActive
|
||||||
|
? `${filtered.length} of ${cluster.items.length} photo${
|
||||||
|
cluster.items.length === 1 ? '' : 's'
|
||||||
|
}`
|
||||||
|
: `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-iconbtn"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={() => setCluster(null)}
|
||||||
|
>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + date range + object chips — all AND-combined. */}
|
||||||
|
<div className="apg-map__sheet-filters">
|
||||||
|
<div className="apg-mapfilter__row">
|
||||||
|
<div className="apg-mapfilter__search">
|
||||||
|
<Icon name="search" size={15} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
aria-label="Search photos at this location"
|
||||||
|
placeholder="Search photos, objects, text…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
{filterActive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-mapfilter__clear"
|
||||||
|
aria-label="Clear filters"
|
||||||
|
title="Clear filters"
|
||||||
|
onClick={resetFilters}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={13} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="apg-mapfilter__row apg-mapfilter__dates">
|
||||||
|
<DateRangeControl
|
||||||
|
from={fromDate}
|
||||||
|
to={toDate}
|
||||||
|
onChange={(f, t) => {
|
||||||
|
setFromDate(f);
|
||||||
|
setToDate(t);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{clusterObjects.length ? (
|
||||||
|
<div className="apg-mapfilter__chips" role="group" aria-label="Filter by object">
|
||||||
|
{clusterObjects.map(({ label, count }) => {
|
||||||
|
const on = objectFilter === label;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'apg-mapfilter__chip',
|
||||||
|
on ? 'apg-mapfilter__chip--on' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => setObjectFilter(on ? null : label)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="apg-mapfilter__chip-n">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="apg-map__sheet-body apg-scroll">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="apg-mapfilter__empty">
|
||||||
|
<Icon name="search" size={26} />
|
||||||
|
<div className="apg-mapfilter__empty-title">No matching photos</div>
|
||||||
|
<div className="apg-mapfilter__empty-sub">
|
||||||
|
Try a different search, date range, or object.
|
||||||
|
</div>
|
||||||
|
<button type="button" className="apg-btn" onClick={resetFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupByTime(filtered, 'day').map((s) => (
|
||||||
|
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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">
|
||||||
|
People and pets are grouped automatically as photos are analyzed. Add more photos and
|
||||||
|
they will appear here.
|
||||||
|
</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,128 @@
|
|||||||
|
'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 lockConfigured = useGallery((s) => s.lockConfigured);
|
||||||
|
const lockUnlocked = useGallery((s) => s.lockUnlocked);
|
||||||
|
const lockError = useGallery((s) => s.lockError);
|
||||||
|
const [pw, setPw] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Locked: a password is set and the user hasn't unlocked this session.
|
||||||
|
if (lockConfigured && !lockUnlocked) {
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
await api.getState().unlockLock(pw);
|
||||||
|
setBusy(false);
|
||||||
|
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);
|
||||||
|
api.getState().clearLockError();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{lockError ? (
|
||||||
|
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }} role="alert">
|
||||||
|
{lockError === 'wrong-password'
|
||||||
|
? 'Incorrect password.'
|
||||||
|
: "Couldn't check the password. Please try again."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="apg-btn apg-btn--primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{busy ? 'Checking…' : '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={lockConfigured ? 'lock' : 'unlock'} size={13} />
|
||||||
|
{lockConfigured ? 'Locked' : 'Lock…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MediaGrid items={items} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'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)}
|
||||||
|
{latest.author ? ` · ${latest.author}` : ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-right" size={16} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+87
@@ -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,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { isContextMenuOpen } from '../components/ContextMenu';
|
||||||
|
import { isModalOpen } from '../components/Modal';
|
||||||
|
import { useGalleryStoreApi } from '../store/context';
|
||||||
|
import { mediaForView } from '../store/selectors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire Apple-Photos-style global keyboard shortcuts.
|
||||||
|
*
|
||||||
|
* `enabled` is a parameter (not a call-site condition) so hook order stays
|
||||||
|
* stable: an embedding host that owns its own key handling passes `false`.
|
||||||
|
*/
|
||||||
|
export function useKeyboardShortcuts(enabled: boolean = true) {
|
||||||
|
const api = useGalleryStoreApi();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
// Never hijack typing in inputs / editable fields.
|
||||||
|
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, then leave full screen.
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// Anything layered above the shell owns Escape first: the lightbox,
|
||||||
|
// editors, the camera, modals and context menus each close themselves.
|
||||||
|
const overlayOpen =
|
||||||
|
Boolean(state.lightboxId) ||
|
||||||
|
Boolean(state.editorId) ||
|
||||||
|
state.cameraOpen ||
|
||||||
|
isModalOpen() ||
|
||||||
|
isContextMenuOpen();
|
||||||
|
if (overlayOpen) return;
|
||||||
|
if (state.objectFocus) state.setObjectFocus(null);
|
||||||
|
else if (state.selection.size) state.clearSelection();
|
||||||
|
else if (state.fullscreen) state.setFullscreen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter / Space — open the (single) selection in the lightbox.
|
||||||
|
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, enabled]);
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
+367
@@ -0,0 +1,367 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactElement, 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'
|
||||||
|
| 'maximize'
|
||||||
|
| 'minimize'
|
||||||
|
| 'pip'
|
||||||
|
| 'tag'
|
||||||
|
| 'document'
|
||||||
|
| 'pin'
|
||||||
|
| 'pencil'
|
||||||
|
| 'mic';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ReactElement`, not the global `JSX.Element`: React 19 removed the global JSX
|
||||||
|
// namespace, so a React-19 host compiling this source would fail to resolve it.
|
||||||
|
const paths: Record<IconName, ReactElement> = {
|
||||||
|
mic: (
|
||||||
|
<>
|
||||||
|
<rect x="9" y="3" width="6" height="11" rx="3" />
|
||||||
|
<path d="M6 11a6 6 0 0 0 12 0" />
|
||||||
|
<path d="M12 17v3M9 20.5h6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
pencil: (
|
||||||
|
<>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
library: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="6" width="18" height="13" rx="2.5" />
|
||||||
|
<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" />
|
||||||
|
),
|
||||||
|
/* Four corner brackets pushing outward — "enter full screen". */
|
||||||
|
maximize: (
|
||||||
|
<>
|
||||||
|
<path d="M4 9.5V5.5A1.5 1.5 0 0 1 5.5 4h4M14.5 4h4A1.5 1.5 0 0 1 20 5.5v4M20 14.5v4a1.5 1.5 0 0 1-1.5 1.5h-4M9.5 20h-4A1.5 1.5 0 0 1 4 18.5v-4" />
|
||||||
|
<path d="m4.8 4.8 4 4M19.2 4.8l-4 4M19.2 19.2l-4-4M4.8 19.2l4-4" opacity="0.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
/* Same brackets pulled inward — "exit full screen". */
|
||||||
|
minimize: (
|
||||||
|
<>
|
||||||
|
<path d="M9.5 4v4a1.5 1.5 0 0 1-1.5 1.5H4M20 9.5h-4A1.5 1.5 0 0 1 14.5 8V4M14.5 20v-4a1.5 1.5 0 0 1 1.5-1.5h4M4 14.5h4a1.5 1.5 0 0 1 1.5 1.5v4" />
|
||||||
|
<path d="m4.8 4.8 3.4 3.4M19.2 4.8l-3.4 3.4M19.2 19.2l-3.4-3.4M4.8 19.2l3.4-3.4" opacity="0.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
pip: (
|
||||||
|
<>
|
||||||
|
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
|
||||||
|
<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" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// @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, DEFAULT_CHROME } from './store/store';
|
||||||
|
export type {
|
||||||
|
GalleryState,
|
||||||
|
GalleryStore,
|
||||||
|
GalleryConfig,
|
||||||
|
GalleryFeatures,
|
||||||
|
GalleryChrome,
|
||||||
|
ThemeTokens,
|
||||||
|
GridFilter,
|
||||||
|
LockProvider,
|
||||||
|
} from './store/store';
|
||||||
|
export * as selectors from './store/selectors';
|
||||||
|
|
||||||
|
// Domain types
|
||||||
|
export type {
|
||||||
|
MediaItem,
|
||||||
|
MediaId,
|
||||||
|
MediaKind,
|
||||||
|
MediaSource,
|
||||||
|
MediaComment,
|
||||||
|
MediaVersion,
|
||||||
|
ShareRecord,
|
||||||
|
GalleryUser,
|
||||||
|
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,
|
||||||
|
StateChanges,
|
||||||
|
StoredBlob,
|
||||||
|
} from './adapters/types';
|
||||||
|
|
||||||
|
// AI provider interface (pluggable; free in-browser or cloud)
|
||||||
|
export type { AIProvider, GenerativeEditOp, TiltEstimate } 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';
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Microphone capture + WAV (PCM16) encoding — browser-only, no external deps.
|
||||||
|
*
|
||||||
|
* Powers the voice-annotation UI: record speech, optionally run it through the
|
||||||
|
* audio-denoise model, then transcribe. Records via MediaRecorder, then decodes
|
||||||
|
* and resamples with the Web Audio API to the mono sample rate each model wants
|
||||||
|
* (16 kHz for speech-to-text, 48 kHz for denoise) and encodes 16-bit PCM WAV.
|
||||||
|
*
|
||||||
|
* All browser globals are touched at call time (never module top-level), so
|
||||||
|
* importing this on the server is safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Recorder {
|
||||||
|
/** Stop recording, release the mic, and resolve with the recorded audio blob. */
|
||||||
|
stop(): Promise<Blob>;
|
||||||
|
/** Abort without producing a blob (still releases the mic). */
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin recording from the default microphone. Rejects if mic access is denied. */
|
||||||
|
export async function startRecording(): Promise<Recorder> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error('Microphone is not available in this browser.');
|
||||||
|
}
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data && e.data.size > 0) chunks.push(e.data);
|
||||||
|
};
|
||||||
|
const release = () => stream.getTracks().forEach((t) => t.stop());
|
||||||
|
recorder.start();
|
||||||
|
|
||||||
|
const collect = () => new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
return new Promise<Blob>((resolve) => {
|
||||||
|
recorder.onstop = () => {
|
||||||
|
release();
|
||||||
|
resolve(collect());
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch {
|
||||||
|
release();
|
||||||
|
resolve(collect());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
release();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode any recorded audio blob, downmix to mono, resample to `sampleRate`, and
|
||||||
|
* return base64 (no `data:` prefix) of a 16-bit PCM WAV.
|
||||||
|
*/
|
||||||
|
export async function blobToWavBase64(blob: Blob, sampleRate: number): Promise<string> {
|
||||||
|
const arrayBuf = await blob.arrayBuffer();
|
||||||
|
const AudioCtx =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
: undefined;
|
||||||
|
if (!AudioCtx) throw new Error('Web Audio is not supported in this browser.');
|
||||||
|
|
||||||
|
const decodeCtx = new AudioCtx();
|
||||||
|
let decoded: AudioBuffer;
|
||||||
|
try {
|
||||||
|
// slice(0) — decodeAudioData detaches the buffer; keep a copy.
|
||||||
|
decoded = await decodeCtx.decodeAudioData(arrayBuf.slice(0));
|
||||||
|
} finally {
|
||||||
|
void decodeCtx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downmix (multi-channel → 1) + resample via an OfflineAudioContext at the target rate.
|
||||||
|
const frames = Math.max(1, Math.round(decoded.duration * sampleRate));
|
||||||
|
const offline = new OfflineAudioContext(1, frames, sampleRate);
|
||||||
|
const source = offline.createBufferSource();
|
||||||
|
source.buffer = decoded;
|
||||||
|
source.connect(offline.destination);
|
||||||
|
source.start();
|
||||||
|
const rendered = await offline.startRendering();
|
||||||
|
return base64FromBytes(new Uint8Array(encodeWavPcm16(rendered.getChannelData(0), sampleRate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn base64 WAV (as returned by the denoise model) back into a Blob. */
|
||||||
|
export function wavBase64ToBlob(base64: string): Blob {
|
||||||
|
const clean = base64.includes(',') ? base64.split(',', 2)[1]! : base64;
|
||||||
|
const bin = atob(clean);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return new Blob([bytes], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWavPcm16(samples: Float32Array, sampleRate: number): ArrayBuffer {
|
||||||
|
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const writeStr = (offset: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
||||||
|
};
|
||||||
|
writeStr(0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + samples.length * 2, true);
|
||||||
|
writeStr(8, 'WAVE');
|
||||||
|
writeStr(12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true); // fmt chunk size
|
||||||
|
view.setUint16(20, 1, true); // audio format = PCM
|
||||||
|
view.setUint16(22, 1, true); // channels = mono
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * 2, true); // byte rate = sampleRate * blockAlign
|
||||||
|
view.setUint16(32, 2, true); // block align = channels * bytesPerSample
|
||||||
|
view.setUint16(34, 16, true); // bits per sample
|
||||||
|
writeStr(36, 'data');
|
||||||
|
view.setUint32(40, samples.length * 2, true);
|
||||||
|
let offset = 44;
|
||||||
|
for (let i = 0; i < samples.length; i++, offset += 2) {
|
||||||
|
const s = Math.max(-1, Math.min(1, samples[i]!));
|
||||||
|
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64FromBytes(bytes: Uint8Array): string {
|
||||||
|
let bin = '';
|
||||||
|
const chunk = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Annotation, EditState } from '../types';
|
||||||
|
import { editFilterCss } from './edits';
|
||||||
|
|
||||||
|
export interface BakeResult {
|
||||||
|
blob: Blob;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
annotations: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the edit stack contains geometry that must be flattened into pixels. */
|
||||||
|
export function hasGeometry(edits?: EditState): boolean {
|
||||||
|
if (!edits) return false;
|
||||||
|
const c = edits.crop;
|
||||||
|
const cropped = !!c && (c.x > 0.001 || c.y > 0.001 || c.width < 0.999 || c.height < 0.999);
|
||||||
|
return cropped || !!edits.rotation || !!edits.straighten || !!edits.flipH || !!edits.flipV;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the visual edit stack (crop → flips → filter → rotation/straighten) of
|
||||||
|
* a (CORS-clean) image into a new JPEG Blob. Annotations are re-mapped into the
|
||||||
|
* cropped space (when un-rotated) so they stay aligned.
|
||||||
|
*/
|
||||||
|
export async function bakeEdits(
|
||||||
|
img: HTMLImageElement,
|
||||||
|
edits: EditState,
|
||||||
|
): Promise<BakeResult> {
|
||||||
|
const natW = img.naturalWidth || img.width;
|
||||||
|
const natH = img.naturalHeight || img.height;
|
||||||
|
const c = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
|
||||||
|
const sx = Math.round(c.x * natW);
|
||||||
|
const sy = Math.round(c.y * natH);
|
||||||
|
const sw = Math.max(1, Math.round(c.width * natW));
|
||||||
|
const sh = Math.max(1, Math.round(c.height * natH));
|
||||||
|
|
||||||
|
// Canvas A: cropped region with flips + colour filter baked in.
|
||||||
|
const a = document.createElement('canvas');
|
||||||
|
a.width = sw;
|
||||||
|
a.height = sh;
|
||||||
|
const actx = a.getContext('2d');
|
||||||
|
if (!actx) throw new Error('Canvas unavailable');
|
||||||
|
const filter = editFilterCss(edits);
|
||||||
|
if (filter) actx.filter = filter;
|
||||||
|
const fH = edits.flipH ? -1 : 1;
|
||||||
|
const fV = edits.flipV ? -1 : 1;
|
||||||
|
actx.save();
|
||||||
|
actx.translate(fH < 0 ? sw : 0, fV < 0 ? sh : 0);
|
||||||
|
actx.scale(fH, fV);
|
||||||
|
actx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||||
|
actx.restore();
|
||||||
|
|
||||||
|
// Optional vignette.
|
||||||
|
const vig = edits.adjustments?.vignette ?? 0;
|
||||||
|
if (vig > 0) {
|
||||||
|
actx.filter = 'none';
|
||||||
|
const g = actx.createRadialGradient(sw / 2, sh / 2, Math.min(sw, sh) * 0.35, sw / 2, sh / 2, Math.max(sw, sh) * 0.75);
|
||||||
|
g.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
g.addColorStop(1, `rgba(0,0,0,${Math.min(0.85, vig * 0.85).toFixed(2)})`);
|
||||||
|
actx.fillStyle = g;
|
||||||
|
actx.fillRect(0, 0, sw, sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation (90/180/270 + fine straighten).
|
||||||
|
const deg = ((edits.rotation ?? 0) + (edits.straighten ?? 0)) % 360;
|
||||||
|
let out = a;
|
||||||
|
if (deg !== 0) {
|
||||||
|
const rad = (deg * Math.PI) / 180;
|
||||||
|
const cos = Math.abs(Math.cos(rad));
|
||||||
|
const sin = Math.abs(Math.sin(rad));
|
||||||
|
const ow = Math.round(sw * cos + sh * sin);
|
||||||
|
const oh = Math.round(sw * sin + sh * cos);
|
||||||
|
const o = document.createElement('canvas');
|
||||||
|
o.width = ow;
|
||||||
|
o.height = oh;
|
||||||
|
const octx = o.getContext('2d');
|
||||||
|
if (!octx) throw new Error('Canvas unavailable');
|
||||||
|
octx.translate(ow / 2, oh / 2);
|
||||||
|
octx.rotate(rad);
|
||||||
|
octx.drawImage(a, -sw / 2, -sh / 2);
|
||||||
|
out = o;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||||
|
out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.92),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-map annotations into the cropped coordinate space (skip when rotated).
|
||||||
|
let annotations: Annotation[] = [];
|
||||||
|
if (edits.annotations?.length && deg === 0) {
|
||||||
|
const remap = (x: number, y: number) => ({
|
||||||
|
x: (x - c.x) / c.width,
|
||||||
|
y: (y - c.y) / c.height,
|
||||||
|
});
|
||||||
|
annotations = edits.annotations.map((an) => {
|
||||||
|
const p1 = remap(an.x1, an.y1);
|
||||||
|
const p2 = remap(an.x2, an.y2);
|
||||||
|
return {
|
||||||
|
...an,
|
||||||
|
x1: p1.x,
|
||||||
|
y1: p1.y,
|
||||||
|
x2: p2.x,
|
||||||
|
y2: p2.y,
|
||||||
|
points: an.points?.map((pt) => remap(pt.x, pt.y)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blob, width: out.width, height: out.height, annotations };
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
import type { MediaSource } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic media-source classification — no AI model required.
|
||||||
|
*
|
||||||
|
* Uses filename patterns, dimensions, mime type and the presence of camera EXIF
|
||||||
|
* data to guess whether a file is a screenshot, a download, a social-app save,
|
||||||
|
* a scanned document, or a real camera capture. This mirrors how Photos
|
||||||
|
* auto-populates its "Screenshots" / "Recents" smart albums.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ClassifyInput {
|
||||||
|
name: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
mime: string;
|
||||||
|
hasCameraExif?: boolean;
|
||||||
|
/** Common device screen sizes help confirm screenshots. */
|
||||||
|
knownScreenSizes?: Array<[number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREENSHOT_NAME = /(screen[\s_-]?shot|screenshot|screen recording|capture)/i;
|
||||||
|
const SOCIAL_NAME = /(whatsapp|img-\d{8}-wa\d+|telegram|instagram|insta|fb_img|facebook|messenger|signal|snapchat)/i;
|
||||||
|
const DOWNLOAD_NAME = /(download|untitled|unsplash|pexels|pixabay|getty|shutterstock|wallpaper)/i;
|
||||||
|
const SCAN_NAME = /(scan|scanned|cam[\s_-]?scanner|doc[\s_-]?scan|adobe[\s_-]?scan)/i;
|
||||||
|
const AI_NAME = /(midjourney|dall[\s_-]?e|stable[\s_-]?diffusion|sdxl|ai[\s_-]?generated|upscayl|gigapixel|remaster)/i;
|
||||||
|
const CAMERA_NAME = /(img_\d+|dsc[_-]?\d+|dscf\d+|p\d{7}|gopr\d+|dji_\d+|_mg_\d+|pxl_\d+)/i;
|
||||||
|
|
||||||
|
const DEFAULT_SCREEN_SIZES: Array<[number, number]> = [
|
||||||
|
[1170, 2532], [1284, 2778], [1080, 2400], [1080, 1920], [1440, 3200],
|
||||||
|
[2532, 1170], [2778, 1284], [2400, 1080], [1920, 1080],
|
||||||
|
[2560, 1440], [3840, 2160], [1280, 800], [2880, 1800], [1366, 768],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function classifyMediaSource(input: ClassifyInput): MediaSource {
|
||||||
|
const { name, width, height, mime, hasCameraExif } = input;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
if (SCREENSHOT_NAME.test(lower)) return 'screenshot'; // screen recording
|
||||||
|
if (SOCIAL_NAME.test(lower)) return 'social';
|
||||||
|
return hasCameraExif ? 'camera' : 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AI_NAME.test(lower)) return 'ai';
|
||||||
|
if (SCAN_NAME.test(lower)) return 'scanned';
|
||||||
|
if (SCREENSHOT_NAME.test(lower)) return 'screenshot';
|
||||||
|
if (SOCIAL_NAME.test(lower)) return 'social';
|
||||||
|
|
||||||
|
// An image at an exact device resolution with no camera EXIF -> almost certainly a screenshot.
|
||||||
|
const sizes = input.knownScreenSizes ?? DEFAULT_SCREEN_SIZES;
|
||||||
|
const matchesScreen = sizes.some(([w, h]) => w === width && h === height);
|
||||||
|
if (!hasCameraExif && matchesScreen) {
|
||||||
|
return 'screenshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CAMERA_NAME.test(lower) || hasCameraExif) return 'camera';
|
||||||
|
if (DOWNLOAD_NAME.test(lower)) return 'download';
|
||||||
|
|
||||||
|
// PNGs without camera data are usually web downloads or graphics, not captures.
|
||||||
|
if (mime === 'image/png' && !hasCameraExif) return 'download';
|
||||||
|
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-friendly label for a media source. */
|
||||||
|
export function sourceLabel(source: MediaSource): string {
|
||||||
|
switch (source) {
|
||||||
|
case 'camera': return 'Camera';
|
||||||
|
case 'screenshot': return 'Screenshot';
|
||||||
|
case 'download': return 'Downloaded';
|
||||||
|
case 'social': return 'Social';
|
||||||
|
case 'scanned': return 'Scanned';
|
||||||
|
case 'ai': return 'AI Generated';
|
||||||
|
case 'imported': return 'Imported';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import type { MediaId, MediaItem } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Face clustering — groups detected faces across the library into people.
|
||||||
|
*
|
||||||
|
* Faces carry a 128-D embedding (from the provider's face-recognition model).
|
||||||
|
* Two faces of the same person sit close in that space; different people sit far
|
||||||
|
* apart. We do a simple, deterministic online clustering: process faces largest
|
||||||
|
* first (big, frontal faces make better seeds), and assign each to the nearest
|
||||||
|
* existing centroid within `threshold`, else start a new cluster.
|
||||||
|
*
|
||||||
|
* Kept dependency-free and pure so it runs anywhere and is trivially testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Max Euclidean distance between L2-ish descriptors for "same person".
|
||||||
|
* face-api's recommended cut-off is 0.6; we use a slightly tighter 0.55 to
|
||||||
|
* favour precision (fewer wrong merges) over recall. */
|
||||||
|
const DEFAULT_THRESHOLD = 0.55;
|
||||||
|
|
||||||
|
export interface FaceCluster {
|
||||||
|
/** Unique media ids in this cluster, ordered by first appearance. */
|
||||||
|
mediaIds: MediaId[];
|
||||||
|
/** Item whose face is the most prominent (largest box) — used as the cover. */
|
||||||
|
coverId: MediaId;
|
||||||
|
/** Mean descriptor of the cluster. */
|
||||||
|
centroid: number[];
|
||||||
|
/** Number of individual faces (not items) merged into this cluster. */
|
||||||
|
faceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function euclidean(a: number[], b: number[]): number {
|
||||||
|
let sum = 0;
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const d = a[i]! - b[i]!;
|
||||||
|
sum += d * d;
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clusterFaces(media: MediaItem[], threshold = DEFAULT_THRESHOLD): FaceCluster[] {
|
||||||
|
// Flatten every embedded face; bigger faces first for stabler seed centroids.
|
||||||
|
const faces: { itemId: MediaId; emb: number[]; area: number }[] = [];
|
||||||
|
for (const m of media) {
|
||||||
|
if (m.deletedAt) continue;
|
||||||
|
for (const f of m.faces ?? []) {
|
||||||
|
if (f.embedding && f.embedding.length > 0) {
|
||||||
|
faces.push({ itemId: m.id, emb: f.embedding, area: f.box.width * f.box.height });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
faces.sort((a, b) => b.area - a.area);
|
||||||
|
|
||||||
|
interface Acc {
|
||||||
|
sum: number[];
|
||||||
|
n: number;
|
||||||
|
centroid: number[];
|
||||||
|
members: { itemId: MediaId; area: number }[];
|
||||||
|
}
|
||||||
|
const clusters: Acc[] = [];
|
||||||
|
|
||||||
|
for (const f of faces) {
|
||||||
|
let best = -1;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < clusters.length; i++) {
|
||||||
|
const d = euclidean(clusters[i]!.centroid, f.emb);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best >= 0 && bestD <= threshold) {
|
||||||
|
const c = clusters[best]!;
|
||||||
|
for (let k = 0; k < f.emb.length; k++) c.sum[k] = (c.sum[k] ?? 0) + f.emb[k]!;
|
||||||
|
c.n += 1;
|
||||||
|
c.centroid = c.sum.map((s) => s / c.n);
|
||||||
|
c.members.push({ itemId: f.itemId, area: f.area });
|
||||||
|
} else {
|
||||||
|
clusters.push({
|
||||||
|
sum: [...f.emb],
|
||||||
|
n: 1,
|
||||||
|
centroid: [...f.emb],
|
||||||
|
members: [{ itemId: f.itemId, area: f.area }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters.map((c) => {
|
||||||
|
const seen = new Set<MediaId>();
|
||||||
|
const mediaIds: MediaId[] = [];
|
||||||
|
let coverId = c.members[0]!.itemId;
|
||||||
|
let coverArea = -1;
|
||||||
|
for (const m of c.members) {
|
||||||
|
if (!seen.has(m.itemId)) {
|
||||||
|
seen.add(m.itemId);
|
||||||
|
mediaIds.push(m.itemId);
|
||||||
|
}
|
||||||
|
if (m.area > coverArea) {
|
||||||
|
coverArea = m.area;
|
||||||
|
coverId = m.itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mediaIds, coverId, centroid: c.centroid, faceCount: c.n };
|
||||||
|
});
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight password hashing for the client-side album lock.
|
||||||
|
*
|
||||||
|
* NOTE: this is a convenience/privacy UX feature, not server-grade auth. The
|
||||||
|
* hash is computed with the Web Crypto API (SHA-256 over a fixed app salt + the
|
||||||
|
* password) and stored locally. It keeps casual onlookers out of the Recently
|
||||||
|
* Deleted view on a shared device; it is NOT a substitute for real authz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const APP_SALT = 'apg::recently-deleted::v1';
|
||||||
|
|
||||||
|
function toHex(buf: ArrayBuffer): string {
|
||||||
|
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns `sha256:<hex>` for the given password, or '' if Web Crypto is absent. */
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const subtle = globalThis.crypto?.subtle;
|
||||||
|
if (!subtle) return '';
|
||||||
|
const data = new TextEncoder().encode(`${APP_SALT}:${password}`);
|
||||||
|
const digest = await subtle.digest('SHA-256', data);
|
||||||
|
return `sha256:${toHex(digest)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time-ish comparison of a candidate password against a stored hash. */
|
||||||
|
export async function verifyPassword(password: string, storedHash: string | null): Promise<boolean> {
|
||||||
|
if (!storedHash) return false;
|
||||||
|
const candidate = await hashPassword(password);
|
||||||
|
if (candidate.length !== storedHash.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < candidate.length; i++) diff |= candidate.charCodeAt(i) ^ storedHash.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import type { MediaItem } from '../types';
|
||||||
|
|
||||||
|
/** Trigger a browser download of a single media item by URL. */
|
||||||
|
export function downloadMedia(item: MediaItem): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = item.src;
|
||||||
|
a.download = sanitizeFilename(item.name);
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export a metadata-only JSON sidecar for the current selection. */
|
||||||
|
export function exportMetadata(items: MediaItem[]): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const payload = items.map((i) => ({
|
||||||
|
name: i.name,
|
||||||
|
takenAt: new Date(i.takenAt).toISOString(),
|
||||||
|
source: i.source,
|
||||||
|
tags: i.tags,
|
||||||
|
objects: i.objectLabels,
|
||||||
|
location: i.location,
|
||||||
|
favorite: i.favorite,
|
||||||
|
width: i.width,
|
||||||
|
height: i.height,
|
||||||
|
}));
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'photos-metadata.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a filename to prevent path traversal / illegal characters when it is
|
||||||
|
* used as a download target. Security-relevant: never trust an imported name.
|
||||||
|
*/
|
||||||
|
export function sanitizeFilename(name: string): string {
|
||||||
|
const cleaned = name
|
||||||
|
// Strip path separators and characters illegal on common filesystems.
|
||||||
|
.replace(/[/\\?%*:|"<>]/g, '_')
|
||||||
|
// Collapse parent-directory sequences (path traversal guard).
|
||||||
|
.replace(/\.\.+/g, '_')
|
||||||
|
.trim();
|
||||||
|
return cleaned.slice(0, 200) || 'photo';
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../constants';
|
||||||
|
import type { EditAdjustments, EditState } from '../types';
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
function resolveAdjustments(edits: EditState): EditAdjustments {
|
||||||
|
const preset = edits.filter ? FILTER_PRESETS[edits.filter]?.adjustments ?? {} : {};
|
||||||
|
return { ...ZERO_ADJUSTMENTS, ...preset, ...edits.adjustments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a CSS `filter` string approximating the adjustment stack. */
|
||||||
|
export function editFilterCss(edits?: EditState): string | undefined {
|
||||||
|
if (!edits) return undefined;
|
||||||
|
const a = resolveAdjustments(edits);
|
||||||
|
|
||||||
|
const brightness = clamp(
|
||||||
|
1 + a.exposure * 0.4 + a.brightness * 0.4 + a.shadows * 0.12 - a.highlights * 0.06,
|
||||||
|
0.2,
|
||||||
|
2.2,
|
||||||
|
);
|
||||||
|
const contrast = clamp(
|
||||||
|
1 + a.contrast * 0.5 + a.definition * 0.2 + a.sharpness * 0.15 + a.blackPoint * 0.25,
|
||||||
|
0.2,
|
||||||
|
2.6,
|
||||||
|
);
|
||||||
|
const saturate = clamp(1 + a.saturation * 0.85 + a.vibrance * 0.35, 0, 3);
|
||||||
|
const sepia = clamp(Math.max(0, a.warmth) * 0.5, 0, 1);
|
||||||
|
const hue = (a.warmth < 0 ? a.warmth * 14 : 0) + a.tint * 14;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
`brightness(${brightness.toFixed(3)})`,
|
||||||
|
`contrast(${contrast.toFixed(3)})`,
|
||||||
|
];
|
||||||
|
// Heavily desaturated presets read as true monochrome.
|
||||||
|
if (saturate <= 0.2) parts.push('grayscale(1)');
|
||||||
|
else parts.push(`saturate(${saturate.toFixed(3)})`);
|
||||||
|
if (sepia > 0.01) parts.push(`sepia(${sepia.toFixed(3)})`);
|
||||||
|
if (Math.abs(hue) > 0.5) parts.push(`hue-rotate(${hue.toFixed(1)}deg)`);
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a CSS `transform` for rotation + straighten + flips. */
|
||||||
|
export function editTransformCss(edits?: EditState): string | undefined {
|
||||||
|
if (!edits) return undefined;
|
||||||
|
const straighten = edits.straighten ?? 0;
|
||||||
|
const rot = (edits.rotation ?? 0) + straighten;
|
||||||
|
// Scale up slightly when straightening so the rotated image still covers the frame.
|
||||||
|
const cover = straighten ? 1 + Math.min(0.5, Math.abs(straighten) / 90) : 1;
|
||||||
|
const sx = (edits.flipH ? -1 : 1) * cover;
|
||||||
|
const sy = (edits.flipV ? -1 : 1) * cover;
|
||||||
|
if (!rot && sx === 1 && sy === 1) return undefined;
|
||||||
|
return `rotate(${rot}deg) scale(${sx}, ${sy})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ZERO_ADJUSTMENTS };
|
||||||
+76
@@ -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)));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user