Feat/leads #34
@@ -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.
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Messaging UI SDK — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-17
|
||||||
|
**Status:** Approved, pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Messaging UI is rewritten from scratch in every app that needs it. `lynkeduppro-crm`
|
||||||
|
has a rich, working messenger — conversation list, thread view, bubbles, reactions,
|
||||||
|
reply threading, typing, read receipts — built as 352 lines in
|
||||||
|
`src/components/dashboard/messenger.tsx` over 371 lines in `src/lib/messenger-api.ts`.
|
||||||
|
None of it is reusable.
|
||||||
|
|
||||||
|
### Why not just use `@insignia/iios-message-web`?
|
||||||
|
|
||||||
|
Because it does not solve this problem, and the CRM already rejected it.
|
||||||
|
|
||||||
|
`iios-message-web` is 109 lines across 2 files. It is fully headless: its only JSX is
|
||||||
|
the context provider element itself. It exports `MessageProvider`, `useThread`,
|
||||||
|
`useMessages` and a `Message` type — nothing more. It ships no components, no CSS, no
|
||||||
|
theming.
|
||||||
|
|
||||||
|
It also guessed its API wrong. `useMessages.send` narrows the options bag to
|
||||||
|
`{ contentRef? }`, while the underlying `MessageSocket.sendMessage` accepts
|
||||||
|
`parentInteractionId`, `mentions`, and `attachment`. Threading, mentions and
|
||||||
|
attachments are unreachable through its public API. The socket is held in a
|
||||||
|
module-private context with no escape hatch. It has zero consumers outside the iios
|
||||||
|
repo, and its own docs reference a `useSendMessage` hook that does not exist.
|
||||||
|
|
||||||
|
The CRM consequently bypassed it and depends on `@insignia/iios-kernel-client`
|
||||||
|
directly.
|
||||||
|
|
||||||
|
**The lesson drives this design:** the headless layer is already an SDK
|
||||||
|
(`iios-kernel-client` — sockets, threads, receipts, typing, published, consumed). A
|
||||||
|
second headless package saves no app any work. The unsolved part is the UI.
|
||||||
|
|
||||||
|
### Why tower is not a consumer
|
||||||
|
|
||||||
|
Tower's messaging is a WhatsApp group ingest → moderate → forward pipeline, not chat.
|
||||||
|
There is no `Conversation` model; `Message` is a captured group post keyed by
|
||||||
|
`senderJid` + `sourceGroupId` with a moderation `status` enum
|
||||||
|
(`RAW/PENDING/APPROVED/...`) — no recipient, no delivery state. "Send" is a BullMQ job
|
||||||
|
rate-limited to 20 forwards/minute to avoid WhatsApp bans. There is no
|
||||||
|
socket.io/websocket/SSE in the browser anywhere in the repo. Its `threads` and
|
||||||
|
`drafts` mean different things than a chat SDK's would.
|
||||||
|
|
||||||
|
Tower would pay the abstraction cost for realtime machinery it never turns on. It is
|
||||||
|
explicitly out of scope.
|
||||||
|
|
||||||
|
## Constraint: one real consumer
|
||||||
|
|
||||||
|
`lynkeduppro-crm` is the only consumer. Genericity is not achievable by intent — it is
|
||||||
|
forced by a second consumer. This design therefore ports only what is already proven
|
||||||
|
in production and refuses to invent abstraction for imagined needs. `iios-message-web`
|
||||||
|
is the cautionary example of the opposite approach.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
One package, `@insignia/messaging-ui`, published to the existing Gitea registry
|
||||||
|
(`https://git.lynkedup.cloud/api/packages/insignia/npm/`). React as a peer dependency.
|
||||||
|
|
||||||
|
```
|
||||||
|
@insignia/messaging-ui
|
||||||
|
. → components + provider + hooks
|
||||||
|
./styles.css → structural CSS + token defaults
|
||||||
|
./adapters/kernel → optional iios-kernel-client adapter
|
||||||
|
./adapters/mock → in-memory adapter for demos/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**The core has zero transport knowledge.** `iios-kernel-client` is reachable only via
|
||||||
|
the optional `./adapters/kernel` subpath, so an app on a different backend never pulls
|
||||||
|
socket code. This is the specific mistake `iios-message-web` made by welding itself to
|
||||||
|
`MessageSocket`.
|
||||||
|
|
||||||
|
This boundary is load-bearing for the actual consumer: the CRM does **not** talk to
|
||||||
|
iios directly. It routes messaging through be-crm's data door (`crm.messenger.*`) via
|
||||||
|
`@abe-kap/appshell-sdk`, socket-primary with a 4s REST poll fallback. An SDK that
|
||||||
|
hardcoded `iios-kernel-client` could not be adopted by the only app that wants it.
|
||||||
|
|
||||||
|
## The adapter contract
|
||||||
|
|
||||||
|
Lifted from the existing `MessengerData`/`ThreadData` interfaces in
|
||||||
|
`src/lib/messenger-api.ts`, which already survived two implementations (live + mock).
|
||||||
|
Two implementations is the minimum real evidence that a seam is genuine rather than
|
||||||
|
imagined. This contract was not designed for an SDK — it earned its shape.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MessagingAdapter {
|
||||||
|
listConversations(): Promise<Conversation[]>;
|
||||||
|
openThread(p: { participantIds: string[]; subject?: string }): Promise<{ threadId: string }>;
|
||||||
|
history(threadId: string): Promise<Message[]>;
|
||||||
|
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
|
||||||
|
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
|
||||||
|
sendTyping(threadId: string): void;
|
||||||
|
markRead(threadId: string, messageId: string): Promise<void>;
|
||||||
|
react?(messageId: string, emoji: string): Promise<void>;
|
||||||
|
upload?(file: File): Promise<{ url: string; mime: string; name: string }>;
|
||||||
|
currentActorId(): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendOpts {
|
||||||
|
parentInteractionId?: string;
|
||||||
|
attachment?: { url: string; mime: string; name: string };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Graceful degradation
|
||||||
|
|
||||||
|
`react` and `upload` are optional. When an adapter omits them the UI hides the
|
||||||
|
reaction picker or the attach button respectively. This is how one component set
|
||||||
|
serves both a full CRM messenger and a stripped-down widget without a `mode` prop.
|
||||||
|
|
||||||
|
### `currentActorId` fixes a live bug
|
||||||
|
|
||||||
|
Today the CRM infers the current actor id by scanning for a message you sent:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/messenger-socket.tsx — current behaviour
|
||||||
|
const mine = socketMsgs.find((m) => m.mine && m.actorId);
|
||||||
|
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
|
||||||
|
```
|
||||||
|
|
||||||
|
Until you have sent a message in a thread, `myActorId` is `null`. Because the REST
|
||||||
|
poll fallback computes `mine: !!myActorId && m.actorId === myActorId`, **every message
|
||||||
|
renders as not-yours** in that state. The root cause is that the kernel's receipt
|
||||||
|
event carries no `threadId`, making it a global stream the CRM compensates for.
|
||||||
|
|
||||||
|
Making identity an explicit adapter responsibility eliminates this class of bug rather
|
||||||
|
than porting it. The two-tier socket/poll fallback stays in the adapter, not the SDK —
|
||||||
|
the CRM's adapter keeps its 4s poll; a socket-only app implements `subscribe` and
|
||||||
|
never polls.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Composable primitives plus one all-in-one for drop-in use:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<Messenger onNewChat={openPicker} /> {/* all-in-one: list + thread */}
|
||||||
|
|
||||||
|
{/* ...or compose: */}
|
||||||
|
<ConversationList onSelect={setId} renderRow={custom} />
|
||||||
|
<ThreadView threadId={id} />
|
||||||
|
<Composer threadId={id} />
|
||||||
|
</MessagingProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
Hooks remain exported (`useConversations`, `useThread`, `useMessages`) so a host
|
||||||
|
wanting entirely custom UI can use the SDK headlessly. This makes `iios-message-web`'s
|
||||||
|
use case a strict subset of this package rather than a competitor.
|
||||||
|
|
||||||
|
### Explicitly out of scope
|
||||||
|
|
||||||
|
- **Inbox.** Coupled to iios semantics, not chat transport. Items are projected
|
||||||
|
server-side by iios from domain events (`MENTION`, `NEEDS_REPLY`, `SUPPORT_UPDATE`,
|
||||||
|
`CRM_OWNER_INTEREST`); authz is OPA policy. A chat SDK cannot own this.
|
||||||
|
- **People picker / directory.** Fed by `crm.messenger.directory`. "Who exists and who
|
||||||
|
may I message" is host and tenant territory. `<Messenger>` takes an `onNewChat`
|
||||||
|
callback; the host renders its own picker.
|
||||||
|
- **Presence.** No consumer needs it.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
Structural CSS with token defaults, overridden by the host. No Tailwind, no CSS-in-JS,
|
||||||
|
no build coupling — the CRM has no shadcn and near-zero Tailwind (its real styling is
|
||||||
|
1142 lines of hand-rolled `dashboard.css` plus inline style objects), so a
|
||||||
|
Tailwind-based SDK would force a restyle of the only consumer.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--msg-font; --msg-radius; --msg-gap;
|
||||||
|
--msg-bubble-own-bg; --msg-bubble-other-bg;
|
||||||
|
--msg-accent; --msg-muted; --msg-surface; --msg-border;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every component accepts `className`; `<Messenger>` accepts a `classNames` slot map for
|
||||||
|
per-part overrides. The CRM's existing `#6366f1 → #8b5cf6` group-avatar gradient
|
||||||
|
becomes a token value rather than a hardcode.
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
The SDK renders attachments (image thumbnail, file chip, download) and calls
|
||||||
|
`adapter.upload(file)`, passing the result into `send`. **Storage, auth, and
|
||||||
|
size/mime limits are host concerns** — baking in an upload target would break the next
|
||||||
|
app. The attach button is hidden when `upload` is absent.
|
||||||
|
|
||||||
|
`MessageSocket.sendMessage` already accepts an `attachment` field, so this exercises
|
||||||
|
an existing wire contract rather than inventing one. No consumer has exercised it yet;
|
||||||
|
the CRM has no file upload anywhere today.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
1. Host constructs an adapter (CRM: wrapping appshell data door + socket).
|
||||||
|
2. `MessagingProvider` holds the adapter in context.
|
||||||
|
3. `useConversations` calls `listConversations`; `useMessages(threadId)` calls
|
||||||
|
`history` then `subscribe`.
|
||||||
|
4. `Composer` calls `send` with optimistic append; on rejection the optimistic message
|
||||||
|
is rolled back and the input text restored (matching current CRM behaviour).
|
||||||
|
5. `subscribe` events reconcile against optimistic state by message id.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Adapter method rejection surfaces via hook `error` state; components render an
|
||||||
|
inline error affordance, never throw.
|
||||||
|
- Optimistic send failure restores composer text — the CRM's current behaviour, kept.
|
||||||
|
- `subscribe` disconnect is the adapter's problem, not the SDK's. The SDK renders a
|
||||||
|
`connected: boolean` from the adapter as a banner (the CRM's existing "Demo mode"
|
||||||
|
banner generalises to this).
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The migration is the validation. There is no second app, so the honest bar is:
|
||||||
|
|
||||||
|
1. Rewrite the CRM's `messenger.tsx` to consume the SDK; its data-door implementation
|
||||||
|
becomes `CrmMessagingAdapter`. **Success = identical behaviour with the 352-line
|
||||||
|
component deleted**, and the mock adapter preserving demo-mode fallback.
|
||||||
|
2. Then `support.tsx`'s `MessageCenter` — currently pure `setTimeout` theatre with no
|
||||||
|
backend — becomes a zero-risk second surface.
|
||||||
|
|
||||||
|
Two surfaces in one app is not a true second consumer. It is the best honest test
|
||||||
|
available of the adapter boundary, and it should be understood as such. **The design
|
||||||
|
should be revisited when a genuine second app appears** rather than treated as settled.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Component tests against the mock adapter** — no network. This is the payoff of the
|
||||||
|
injected seam.
|
||||||
|
- **`CrmMessagingAdapter` tested against the contract** independently of UI.
|
||||||
|
- **A shared adapter conformance suite** any adapter can run, so the kernel and CRM
|
||||||
|
adapters are verified against one definition of correct.
|
||||||
|
- Explicit regression test for the `currentActorId` bug: messages render as own before
|
||||||
|
the user has sent anything in the thread.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Package name: `@insignia/messaging-ui` assumed, not confirmed.
|
||||||
|
- Whether `CrmMessagingAdapter` lives in the CRM repo or ships as
|
||||||
|
`./adapters/crm`. Preference: the CRM repo — it depends on appshell-sdk, which the
|
||||||
|
SDK must not.
|
||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// The SDK ships ESM/TS; let Next transpile it.
|
// The SDKs ship ESM/TS source (their `exports` point at src/), so Next must
|
||||||
transpilePackages: ["@abe-kap/appshell-sdk"],
|
// 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
+1073
-22
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -6,20 +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.7",
|
||||||
|
"@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,50 @@
|
|||||||
|
/* Web Push service worker for CRM offline notifications.
|
||||||
|
*
|
||||||
|
* Receives push payloads from IIOS (WebPushDelivery), shows a system notification, and on click
|
||||||
|
* focuses an open CRM tab (or opens one) and posts the thread to deep-link to. The payload shape is
|
||||||
|
* IIOS's NotificationPayload: { title, body, data: { threadId, interactionId? } }.
|
||||||
|
*
|
||||||
|
* Served from /public at /push-sw.js → root scope ('/'), so it controls the whole app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
payload = { title: "New notification", body: event.data ? event.data.text() : "" };
|
||||||
|
}
|
||||||
|
const title = payload.title || "New message";
|
||||||
|
const threadId = payload.data && payload.data.threadId;
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body: payload.body || "",
|
||||||
|
// Coalesce repeated pings for the same thread into one notification.
|
||||||
|
tag: threadId ? `thread:${threadId}` : undefined,
|
||||||
|
renotify: Boolean(threadId),
|
||||||
|
data: payload.data || {},
|
||||||
|
icon: "/icons/i_bell.svg",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const threadId = event.notification.data && event.notification.data.threadId;
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
// Prefer an already-open CRM tab: focus it and tell the app which thread to open.
|
||||||
|
for (const client of all) {
|
||||||
|
if ("focus" in client) {
|
||||||
|
await client.focus();
|
||||||
|
client.postMessage({ type: "notif-click", threadId: threadId || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No tab open — launch one deep-linked via the query string.
|
||||||
|
const url = threadId ? `/dashboard?thread=${encodeURIComponent(threadId)}` : "/dashboard";
|
||||||
|
if (self.clients.openWindow) await self.clients.openWindow(url);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,21 @@
|
|||||||
|
|
||||||
.dash-content { padding: 22px 28px 40px; width: 100%; }
|
.dash-content { padding: 22px 28px 40px; width: 100%; }
|
||||||
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
|
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
|
||||||
|
|
||||||
|
/* Host for @insignia/iios-messaging-ui: a fixed-height card that maps the SDK's --miu-* tokens
|
||||||
|
onto the CRM design system, so the drop-in SDK matches the rest of the app. */
|
||||||
|
.dash-root .miu-host { height: 620px; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
|
||||||
|
.dash-root .miu-host .miu-messenger,
|
||||||
|
.dash-root .miu-host .miu-inbox {
|
||||||
|
--miu-bg: var(--bg);
|
||||||
|
--miu-panel: var(--panel);
|
||||||
|
--miu-panel-2: var(--panel-2);
|
||||||
|
--miu-border: var(--border);
|
||||||
|
--miu-text: var(--text);
|
||||||
|
--miu-muted: var(--muted);
|
||||||
|
--miu-accent: var(--orange);
|
||||||
|
--miu-accent-text: #1a1206;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- grid helpers ---- */
|
/* ---- grid helpers ---- */
|
||||||
.grid { display: grid; gap: 16px; }
|
.grid { display: grid; gap: 16px; }
|
||||||
@@ -1383,4 +1398,123 @@
|
|||||||
.dash-root .nl-grid { grid-template-columns: 1fr; }
|
.dash-root .nl-grid { grid-template-columns: 1fr; }
|
||||||
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
|
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Smart Gallery — the embedded @photo-gallery/sdk surface.
|
||||||
|
The SDK is themed entirely through the token map in lib/gallery-api.ts
|
||||||
|
(--apg-* -> this file's own vars), so the rules below only handle the
|
||||||
|
host chrome: sizing, the demo banner, and the load/error placeholders.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* The gallery is the one view that wants the whole viewport: it has its own sidebar, toolbar and
|
||||||
|
scrollers, so any height we leave on the table is wasted chrome. The old big PageHead cost ~90px;
|
||||||
|
a slim header (~40px) + compact banner + tight gaps hand almost all of that back to the shell.
|
||||||
|
96px = the dashboard's top padding + the slim header row; `.gal-shell` (flex:1; min-height:0)
|
||||||
|
consumes whatever is left after the header and the optional demo banner. */
|
||||||
|
.dash-root .gal { display: flex; flex-direction: column; gap: 10px; height: calc(100vh - 96px); min-height: 700px; }
|
||||||
|
|
||||||
|
/* Slim inline header — replaces the tall PageHead. One row, ~40px, so the shell keeps the height. */
|
||||||
|
.dash-root .gal-head { display: flex; align-items: center; gap: 10px; min-height: 36px; flex: 0 0 auto; }
|
||||||
|
.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 .gal-head-title { font-size: 16px; font-weight: 700; line-height: 1.1; margin: 0; }
|
||||||
|
.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; }
|
||||||
|
@media (max-width: 720px) { .dash-root .gal-head-sub { display: none; } }
|
||||||
|
|
||||||
|
/* `.view` sets `z-index: 1`, which makes it a stacking context and traps the SDK's
|
||||||
|
full-screen overlays (lightbox z1000, editors z1050, camera z1080, modals z1100)
|
||||||
|
underneath the topbar's `z-index: 20`. Opting this one view out of the stacking
|
||||||
|
context lets those overlays cover the whole dashboard, as they must. The view still
|
||||||
|
paints above the ambient `.dash-content::before` glow because it follows it in the DOM. */
|
||||||
|
.dash-root .view.gal { z-index: auto; }
|
||||||
|
|
||||||
|
/* 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 ---- */
|
||||||
|
.dash-root .settings-section { margin-top: 8px; }
|
||||||
|
.dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
|
||||||
|
.dash-root .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
|
||||||
|
.dash-root .settings-card { border: 1px solid var(--border); background: var(--panel); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.dash-root .settings-card.is-soon { opacity: 0.6; }
|
||||||
|
.dash-root .settings-card-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||||
|
.dash-root .settings-card-ic { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||||
|
.dash-root .settings-card-titles { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.dash-root .settings-card-name { font-size: 15px; font-weight: 700; color: var(--text); }
|
||||||
|
.dash-root .settings-card-sub { font-weight: 500; color: var(--muted); }
|
||||||
|
.dash-root .settings-card-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.dash-root .settings-card-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.dash-root .settings-kv { display: grid; gap: 10px; margin: 0; }
|
||||||
|
.dash-root .settings-kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||||
|
.dash-root .settings-kv > div:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
.dash-root .settings-kv dt { font-size: 12.5px; color: var(--muted); }
|
||||||
|
.dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
.dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
|
||||||
|
.dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; }
|
||||||
|
.dash-root .settings-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
||||||
|
|
||||||
|
/* ---- Global conversation search (topbar) ---- */
|
||||||
|
.dash-root .gs-wrap { position: relative; }
|
||||||
|
.dash-root .gs-field { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 42vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
|
||||||
|
.dash-root .gs-field:focus-within { border-color: var(--orange); }
|
||||||
|
.dash-root .gs-input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; }
|
||||||
|
.dash-root .gs-input::placeholder { color: var(--muted); }
|
||||||
|
.dash-root .gs-pop { position: absolute; top: calc(100% + 6px); right: 0; width: 420px; max-width: 90vw; max-height: 420px; overflow-y: auto; padding: 6px; border-radius: 14px; border: 1px solid var(--border); background: var(--panel); box-shadow: 0 24px 60px -20px rgba(0,0,0,0.55); z-index: 60; }
|
||||||
|
.dash-root .gs-empty { padding: 14px; font-size: 13px; color: var(--muted); text-align: center; }
|
||||||
|
.dash-root .gs-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: none; border-radius: 10px; cursor: pointer; text-align: left; color: var(--text); }
|
||||||
|
.dash-root .gs-row:hover { background: var(--panel-2); }
|
||||||
|
.dash-root .gs-ic { flex: 0 0 auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||||
|
.dash-root .gs-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.dash-root .gs-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-root .gs-snippet { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; }
|
||||||
|
.dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
||||||
|
@media (max-width: 720px) { .dash-root .gs-field { width: 160px; } }
|
||||||
|
|
||||||
|
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
|
||||||
|
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ import { Support } from "./support";
|
|||||||
import { Rules } from "./rules";
|
import { Rules } from "./rules";
|
||||||
import { AiAssistant } from "./ai-assistant";
|
import { AiAssistant } from "./ai-assistant";
|
||||||
import { TeamManagement } from "./team-management";
|
import { TeamManagement } from "./team-management";
|
||||||
import { Messenger } from "./messenger";
|
import { MessengerSdk } from "./messenger-sdk";
|
||||||
import { Inbox } from "./inbox";
|
import { InboxSdk } from "./inbox-sdk";
|
||||||
|
import { Settings } from "./settings";
|
||||||
|
import { NotificationCenter } from "./notification-center";
|
||||||
|
import { RealtimeProvider } from "@/lib/realtime";
|
||||||
|
import { SmartGallery } from "./smart-gallery";
|
||||||
import { Leads } from "./leads";
|
import { Leads } from "./leads";
|
||||||
import { Verify } from "./verify";
|
import { Verify } from "./verify";
|
||||||
import "../../app/dashboard/dashboard.css";
|
import "../../app/dashboard/dashboard.css";
|
||||||
@@ -24,12 +28,38 @@ import "../../app/dashboard/dashboard.css";
|
|||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||||
const [active, setActive] = useState("dashboard");
|
const [active, setActive] = useState("dashboard");
|
||||||
|
// Deep link from global search: which conversation to focus once we switch tabs.
|
||||||
|
const [deepLink, setDeepLink] = useState<{ surface: "messenger" | "inbox"; threadId: string } | null>(null);
|
||||||
|
|
||||||
|
function navigateToConversation(surface: "messenger" | "inbox", threadId: string) {
|
||||||
|
setActive(surface);
|
||||||
|
setDeepLink({ surface, threadId });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Sync the persisted theme from localStorage (an external system) on mount.
|
// Sync the persisted theme from localStorage (an external system) on mount.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
|
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Deep-link from a clicked push notification. Two paths from the service worker:
|
||||||
|
// - a tab was already open → it postMessages { type: 'notif-click', threadId } to focus here
|
||||||
|
// - no tab was open → it opens /dashboard?thread=<id>, which we read once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const t = new URLSearchParams(window.location.search).get("thread");
|
||||||
|
if (t) {
|
||||||
|
navigateToConversation("messenger", t);
|
||||||
|
window.history.replaceState({}, "", window.location.pathname);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
const onMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data?.type === "notif-click" && e.data.threadId) navigateToConversation("messenger", e.data.threadId);
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener("message", onMessage);
|
||||||
|
return () => navigator.serviceWorker.removeEventListener("message", onMessage);
|
||||||
|
}, []);
|
||||||
function toggle() {
|
function toggle() {
|
||||||
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
|
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
|
||||||
}
|
}
|
||||||
@@ -40,17 +70,21 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash-root" data-theme={theme}>
|
<div className="dash-root" data-theme={theme}>
|
||||||
|
<RealtimeProvider>
|
||||||
<Sidebar active={active} onSelect={setActive} />
|
<Sidebar active={active} onSelect={setActive} />
|
||||||
<div className="dash-main">
|
<div className="dash-main">
|
||||||
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} />
|
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} />
|
||||||
<div className="dash-content">
|
<div className="dash-content">
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<NotificationCenter active={active} onNavigate={navigateToConversation} />
|
||||||
{active === "profile" ? <Profile />
|
{active === "profile" ? <Profile />
|
||||||
: active === "support" ? <Support />
|
: active === "support" ? <Support />
|
||||||
: active === "rules" ? <Rules />
|
: active === "rules" ? <Rules />
|
||||||
: active === "ai" ? <AiAssistant />
|
: active === "ai" ? <AiAssistant />
|
||||||
: active === "messenger" ? <Messenger />
|
: active === "messenger" ? <MessengerSdk focusThreadId={deepLink?.surface === "messenger" ? deepLink.threadId : null} />
|
||||||
: active === "inbox" ? <Inbox />
|
: active === "inbox" ? <InboxSdk focusThreadId={deepLink?.surface === "inbox" ? deepLink.threadId : null} />
|
||||||
|
: active === "settings" ? <Settings />
|
||||||
|
: active === "gallery" ? <SmartGallery theme={theme} />
|
||||||
: active === "leads" ? <Leads />
|
: active === "leads" ? <Leads />
|
||||||
: active === "verify" ? <Verify />
|
: active === "verify" ? <Verify />
|
||||||
: active === "team" ? <TeamManagement />
|
: active === "team" ? <TeamManagement />
|
||||||
@@ -58,6 +92,7 @@ export function Dashboard() {
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RealtimeProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Global conversation search in the topbar: type → debounced crm.search → dropdown of hits; click a
|
||||||
|
// hit to deep-link to exactly where it lives (mail → Inbox, chat → Messenger, on that thread).
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import { useGlobalSearch, type SearchResult } from "@/lib/search-api";
|
||||||
|
|
||||||
|
/** Escape HTML but keep the engine's <em> highlight tags — so a match snippet can't inject markup. */
|
||||||
|
function safeSnippet(s: string): string {
|
||||||
|
const esc = s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
return esc.replace(/<em>/g, "<em>").replace(/<\/em>/g, "</em>");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||||
|
const search = useGlobalSearch();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = q.trim();
|
||||||
|
if (!term) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
search(term)
|
||||||
|
.then((r) => { if (alive) setResults(r); })
|
||||||
|
.catch(() => { if (alive) setResults([]); })
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
}, 220);
|
||||||
|
return () => { alive = false; clearTimeout(t); };
|
||||||
|
}, [q, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDown(e: MouseEvent) {
|
||||||
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function pick(r: SearchResult) {
|
||||||
|
onNavigate(r.surface, r.threadId);
|
||||||
|
setOpen(false);
|
||||||
|
setQ("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gs-wrap" ref={wrapRef}>
|
||||||
|
<div className="gs-field">
|
||||||
|
<Icon name="search" size={16} />
|
||||||
|
<input
|
||||||
|
className="gs-input"
|
||||||
|
placeholder="Search conversations…"
|
||||||
|
value={q}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||||
|
aria-label="Search conversations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{open && q.trim() ? (
|
||||||
|
<div className="gs-pop">
|
||||||
|
{loading && results.length === 0 ? <div className="gs-empty">Searching…</div> : null}
|
||||||
|
{!loading && results.length === 0 ? <div className="gs-empty">No matches.</div> : null}
|
||||||
|
{results.map((r) => (
|
||||||
|
<button key={r.interactionId} type="button" className="gs-row" onClick={() => pick(r)}>
|
||||||
|
<span className="gs-ic"><Icon name={r.surface === "inbox" ? "mail" : "send"} size={14} /></span>
|
||||||
|
<span className="gs-main">
|
||||||
|
<span className="gs-title">{r.title}</span>
|
||||||
|
<span className="gs-snippet" dangerouslySetInnerHTML={{ __html: safeSnippet(r.snippet) }} />
|
||||||
|
</span>
|
||||||
|
<span className="gs-surface">{r.surface === "inbox" ? "Mail" : "Chat"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The CRM Inbox, rendered by @insignia/iios-messaging-ui instead of the bespoke in-CRM inbox.
|
||||||
|
// Live = the be-crm data door (CrmInboxAdapter over crm.inbox.* + crm.mail.*); demo = the SDK's
|
||||||
|
// MockInboxAdapter.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { InboxProvider, Inbox as SdkInbox, type InboxAdapter } from "@insignia/iios-messaging-ui";
|
||||||
|
import { MockInboxAdapter } from "@insignia/iios-messaging-ui/adapters/mock-inbox";
|
||||||
|
import "@insignia/iios-messaging-ui/styles.css";
|
||||||
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
import { CrmInboxAdapter } from "@/lib/crm-inbox-adapter";
|
||||||
|
import type { DataDoor } from "@/lib/crm-messaging-adapter";
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
export function InboxSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||||
|
return (
|
||||||
|
<div className="view">
|
||||||
|
{!SHELL && (
|
||||||
|
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
|
||||||
|
Demo mode — running on the SDK's mock inbox adapter.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox focusThreadId={focusThreadId} /> : <DemoInbox focusThreadId={focusThreadId} />}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemoInbox({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||||
|
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
|
||||||
|
return (
|
||||||
|
<InboxProvider adapter={adapter}>
|
||||||
|
<SdkInbox focusThreadId={focusThreadId} />
|
||||||
|
</InboxProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveInbox({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
|
||||||
|
return (
|
||||||
|
<InboxProvider adapter={adapter}>
|
||||||
|
<SdkInbox focusThreadId={focusThreadId} />
|
||||||
|
</InboxProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Inbox — the ONE unified communication surface. It lists everything
|
|
||||||
// IIOS surfaces for you (mentions, needs-reply, system alerts, support
|
|
||||||
// updates, …) AND the mail behind them: click an item tied to a thread
|
|
||||||
// and its conversation opens on the right to read + reply. Compose new
|
|
||||||
// mail from here too. Items come from crm.inbox.*; threads from crm.mail.*.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Btn, Icon, PageHead, Pill, useToast } from "./ui";
|
|
||||||
import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api";
|
|
||||||
import { MailReader, NewMailModal } from "./mail";
|
|
||||||
|
|
||||||
const KIND_LABEL: Record<string, string> = {
|
|
||||||
MAIL: "Mail",
|
|
||||||
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
|
|
||||||
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
|
|
||||||
};
|
|
||||||
const FILTERS: { value: InboxState; label: string }[] = [
|
|
||||||
{ value: "OPEN", label: "Open" }, { value: "SNOOZED", label: "Snoozed" }, { value: "DONE", label: "Done" }, { value: "ARCHIVED", label: "Archived" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Inbox() {
|
|
||||||
const [filter, setFilter] = useState<InboxState>("OPEN");
|
|
||||||
const inbox = useInboxData(filter);
|
|
||||||
const toast = useToast();
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [newOpen, setNewOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ((!selectedId || !inbox.items.some((i) => i.id === selectedId)) && inbox.items[0]) setSelectedId(inbox.items[0].id);
|
|
||||||
}, [inbox.items, selectedId]);
|
|
||||||
|
|
||||||
const selected = inbox.items.find((i) => i.id === selectedId) ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="view">
|
|
||||||
<PageHead
|
|
||||||
eyebrow="Communication" title="Inbox" subtitle="Mentions, messages, system alerts and mail — all in one place" icon="bell"
|
|
||||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New mail</Btn>}
|
|
||||||
/>
|
|
||||||
{!inbox.live && (
|
|
||||||
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
|
|
||||||
Demo mode — running on mock data. It goes live once the Shell + be-crm are connected.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: 14, flexWrap: "wrap" }}>
|
|
||||||
{FILTERS.map((f) => (
|
|
||||||
<Btn key={f.value} variant={filter === f.value ? "primary" : "outline"} onClick={() => setFilter(f.value)}>{f.label}</Btn>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
|
|
||||||
{/* Left — the unified item list */}
|
|
||||||
<aside style={{ width: 360, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
|
|
||||||
{inbox.loading && <div style={{ padding: 20, color: "var(--muted)" }}>Loading…</div>}
|
|
||||||
{!inbox.loading && inbox.items.length === 0 && (
|
|
||||||
<div style={{ padding: 28, color: "var(--muted)", textAlign: "center" }}>Nothing here — you're all caught up 🎉</div>
|
|
||||||
)}
|
|
||||||
{inbox.items.map((it) => (
|
|
||||||
<ItemRow key={it.id} it={it} active={it.id === selectedId} onClick={() => setSelectedId(it.id)} />
|
|
||||||
))}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Right — read the mail behind the item, or the item detail */}
|
|
||||||
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "var(--bg)" }}>
|
|
||||||
{selected ? (
|
|
||||||
<Detail
|
|
||||||
it={selected}
|
|
||||||
onError={(m) => toast.push({ tone: "error", title: "Failed", desc: m })}
|
|
||||||
onDone={() => inbox.transition(selected.id, "DONE")}
|
|
||||||
onSnooze={() => inbox.transition(selected.id, "SNOOZED")}
|
|
||||||
onArchive={() => inbox.transition(selected.id, "ARCHIVED")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
|
|
||||||
<Icon name="bell" size={38} /><p>Select an item to read</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewMailModal
|
|
||||||
open={newOpen} onClose={() => setNewOpen(false)}
|
|
||||||
onSent={() => { setNewOpen(false); inbox.refetch(); toast.push({ tone: "success", title: "Sent" }); }}
|
|
||||||
onError={(m) => toast.push({ tone: "error", title: "Couldn't send", desc: m })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; onClick: () => void }) {
|
|
||||||
const isMention = it.kind === "MENTION";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
display: "flex", gap: 10, alignItems: "flex-start", width: "100%", textAlign: "left",
|
|
||||||
padding: "13px 16px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
|
|
||||||
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
|
|
||||||
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
|
|
||||||
</span>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
|
|
||||||
<Pill tone={isMention ? "warn" : "muted"}>{KIND_LABEL[it.kind] ?? it.kind}</Pill>
|
|
||||||
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
|
|
||||||
</div>
|
|
||||||
{it.summary && <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 3, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.summary}</div>}
|
|
||||||
</div>
|
|
||||||
{it.state !== "OPEN" && <Pill tone="muted">{it.state.toLowerCase()}</Pill>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Detail({ it, onError, onDone, onSnooze, onArchive }: {
|
|
||||||
it: UiInboxItem; onError: (m: string) => void; onDone: () => void; onSnooze: () => void; onArchive: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Item actions bar — only for real inbox work-items. Mail isn't an inbox item
|
|
||||||
(no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */}
|
|
||||||
{it.state === "OPEN" && it.kind !== "MAIL" && (
|
|
||||||
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
|
|
||||||
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
|
|
||||||
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
|
|
||||||
<Btn variant="ghost" icon="x" onClick={onArchive}>Archive</Btn>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{it.threadId ? (
|
|
||||||
// A message/mail item → open the conversation to read + reply.
|
|
||||||
// key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the
|
|
||||||
// variables — so switching items must remount MailReader to load the new thread's history.
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// A non-threaded item (e.g. a system alert) → show its detail.
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: 22 }}>
|
|
||||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>{it.title}</div>
|
|
||||||
{it.summary && <div style={{ color: "var(--muted)", fontSize: 14, lineHeight: 1.55 }}>{it.summary}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Mail components used INSIDE the Inbox (not a separate tab).
|
|
||||||
// The Inbox is the one unified surface — mentions, system messages
|
|
||||||
// and mail all live there. These render the mail body + reply, and
|
|
||||||
// compose a new message. HTML bodies render in a sandboxed iframe.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
|
||||||
import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui";
|
|
||||||
import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api";
|
|
||||||
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
|
|
||||||
|
|
||||||
const timeOf = (iso?: string) => {
|
|
||||||
if (!iso) return "";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
||||||
};
|
|
||||||
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
|
|
||||||
const inputStyle: CSSProperties = {
|
|
||||||
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
|
|
||||||
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
|
||||||
if (!n) return "";
|
|
||||||
if (n < 1024) return `${n} B`;
|
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
|
||||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */
|
|
||||||
function MailAttachmentView({ att }: { att: MailAttachment }) {
|
|
||||||
const getUrl = useDownloadUrl();
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [att.contentRef, att.mimeType, getUrl]);
|
|
||||||
|
|
||||||
const label = att.filename || "Attachment";
|
|
||||||
if (isImage(att.mimeType)) {
|
|
||||||
return url
|
|
||||||
? <a href={url} target="_blank" rel="noreferrer" style={{ display: "inline-block" }}><img src={url} alt={label} style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, border: "1px solid var(--border)" }} /></a>
|
|
||||||
: <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading image…</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a href={url ?? "#"} target="_blank" rel="noreferrer"
|
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--text)", textDecoration: "none", maxWidth: 320 }}>
|
|
||||||
<Icon name="paperclip" size={18} />
|
|
||||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
|
||||||
{att.sizeBytes > 0 && <span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(att.sizeBytes)}</span>}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A small staged-file chip shown in a composer before send, with a remove button. */
|
|
||||||
function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 10px", borderRadius: 999, background: "var(--panel-2)", border: "1px solid var(--border)", fontSize: 13 }}>
|
|
||||||
<Icon name="paperclip" size={14} />
|
|
||||||
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.filename}</span>
|
|
||||||
<span style={{ color: "var(--muted)" }}>{fmtBytes(file.sizeBytes)}</span>
|
|
||||||
<button onClick={onRemove} title="Remove" style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", padding: 0, lineHeight: 1 }}>✕</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */
|
|
||||||
export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) {
|
|
||||||
const t = useMailThread(threadId);
|
|
||||||
const upload = useUploadAttachment();
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
e.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
setUploading(true);
|
|
||||||
try { setStaged(await upload(file)); }
|
|
||||||
catch (err) { onError((err as Error).message); }
|
|
||||||
finally { setUploading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reply() {
|
|
||||||
const text = draft.trim();
|
|
||||||
if ((!text && !staged) || sending) return;
|
|
||||||
const att = staged ?? undefined;
|
|
||||||
setDraft(""); setStaged(null); setSending(true);
|
|
||||||
try { await t.reply(text, att); }
|
|
||||||
catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); }
|
|
||||||
finally { setSending(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
|
||||||
<header style={{ padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
|
|
||||||
<div style={{ fontWeight: 700, fontSize: 15 }}>{subject || "(no subject)"}</div>
|
|
||||||
</header>
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
||||||
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading…</div>}
|
|
||||||
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages.</div>}
|
|
||||||
{t.messages.map((m) => (
|
|
||||||
<div key={m.interactionId} style={{ border: "1px solid var(--border)", borderRadius: 12, background: "var(--panel)", overflow: "hidden" }}>
|
|
||||||
<div style={{ padding: "7px 12px", borderBottom: "1px solid var(--border)", display: "flex", justifyContent: "space-between", fontSize: 12, color: "var(--muted)" }}>
|
|
||||||
<span>{m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""}</span>
|
|
||||||
<span>{timeOf(m.occurredAt)}</span>
|
|
||||||
</div>
|
|
||||||
{m.html
|
|
||||||
? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} />
|
|
||||||
: m.text
|
|
||||||
? <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>
|
|
||||||
: null}
|
|
||||||
{m.attachment && <div style={{ padding: 12, paddingTop: m.html || m.text ? 0 : 12 }}><MailAttachmentView att={m.attachment} /></div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<footer style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}>
|
|
||||||
{staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
|
|
||||||
<Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
|
|
||||||
<input
|
|
||||||
value={draft} onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
|
|
||||||
placeholder="Reply…" style={inputStyle}
|
|
||||||
/>
|
|
||||||
<Btn icon="send" onClick={() => void reply()} disabled={sending || (!draft.trim() && !staged)}>Reply</Btn>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compose a new message — in-app (to a person) or external (to an email). */
|
|
||||||
export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) {
|
|
||||||
const compose = useMailCompose(onSent);
|
|
||||||
const upload = useUploadAttachment();
|
|
||||||
const [mode, setMode] = useState<"internal" | "external">("internal");
|
|
||||||
const [recipient, setRecipient] = useState("");
|
|
||||||
const [subject, setSubject] = useState("");
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [staged, setStaged] = useState<UploadedAttachment[]>([]);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); setStaged([]); setUploading(false); } }, [open]);
|
|
||||||
|
|
||||||
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const files = Array.from(e.target.files ?? []);
|
|
||||||
e.target.value = "";
|
|
||||||
if (!files.length) return;
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
const uploaded = await Promise.all(files.map((f) => upload(f)));
|
|
||||||
setStaged((s) => [...s, ...uploaded].slice(0, 10));
|
|
||||||
} catch (err) { onError((err as Error).message); }
|
|
||||||
finally { setUploading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
|
||||||
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy && !uploading;
|
|
||||||
|
|
||||||
async function send() {
|
|
||||||
if (!canSend) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim(), staged.length ? staged : undefined);
|
|
||||||
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), staged.length ? { attachments: staged } : undefined);
|
|
||||||
} catch (e) { onError((e as Error).message); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open} onClose={onClose} title="New message" subtitle={mode === "internal" ? "To a team member or client (in-app)" : "To an email address"} icon="chat"
|
|
||||||
footer={<>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn icon="send" onClick={() => void send()} disabled={!canSend}>{busy ? "Sending…" : "Send"}</Btn>
|
|
||||||
</>}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
|
||||||
<Btn variant={mode === "internal" ? "primary" : "outline"} onClick={() => { setMode("internal"); setRecipient(""); }}>In-app</Btn>
|
|
||||||
<Btn variant={mode === "external" ? "primary" : "outline"} onClick={() => { setMode("external"); setRecipient(""); }}>Email</Btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "internal" ? (
|
|
||||||
<Field label="To (person)">
|
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
|
|
||||||
<div style={{ maxHeight: 180, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No people found.</div>}
|
|
||||||
{filtered.map((p: MailPerson) => (
|
|
||||||
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: recipient === p.id ? "var(--panel-2)" : "transparent" }}>
|
|
||||||
<input type="radio" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
|
|
||||||
<Avatar initials={(p.name.split(/\s+/).map((s) => s[0]).join("").slice(0, 2) || "?").toUpperCase()} size={26} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
|
||||||
<span style={{ flex: 1 }}>{p.name}</span>
|
|
||||||
<Pill tone="muted">{p.kind}</Pill>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
) : (
|
|
||||||
<Field label="To (email)">
|
|
||||||
<input value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" style={inputStyle} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
|
|
||||||
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
|
|
||||||
|
|
||||||
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
|
|
||||||
Attach button's click via label→input association and open the picker erratically. */}
|
|
||||||
<div className="ds-field">
|
|
||||||
<span className="ds-field-lbl">Attachments</span>
|
|
||||||
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
|
||||||
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
|
|
||||||
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
|
||||||
|
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
|
||||||
|
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
|
||||||
|
// demo path = the SDK's own MockAdapter.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||||
|
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
|
||||||
|
import "@insignia/iios-messaging-ui/styles.css";
|
||||||
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
import { useRealtime } from "@/lib/realtime";
|
||||||
|
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||||
|
return (
|
||||||
|
<div className="view">
|
||||||
|
{!SHELL && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "0 0 14px",
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--panel-2)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: 13,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Demo mode — running on the SDK's mock adapter.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="miu-host">{SHELL ? <LiveHost focusThreadId={focusThreadId} /> : <DemoHost focusThreadId={focusThreadId} />}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
|
||||||
|
// (Rules-of-Hooks safe — the other branch never renders).
|
||||||
|
function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||||
|
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
|
||||||
|
return (
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<SdkMessenger focusThreadId={focusThreadId} />
|
||||||
|
</MessagingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const socket = useRealtime();
|
||||||
|
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
|
||||||
|
const adapter = useMemo<MessagingAdapter | null>(
|
||||||
|
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
|
||||||
|
[sdk, user?.id, socket],
|
||||||
|
);
|
||||||
|
if (!adapter) return <div className="miu-empty">Loading…</div>;
|
||||||
|
return (
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<SdkMessenger focusThreadId={focusThreadId} />
|
||||||
|
</MessagingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Messenger — internal team + client chat, powered by IIOS via
|
|
||||||
// the be-crm data door (crm.messenger.*). Conversation list ⇄
|
|
||||||
// thread view + composer, with a "new chat" people picker that
|
|
||||||
// creates a DM (1 person) or group (2+). DM-vs-group and who-can-
|
|
||||||
// chat are enforced server-side by IIOS/OPA; this is just UI.
|
|
||||||
// Live messages, typing, read receipts and reactions come over the
|
|
||||||
// IIOS socket (Shell mode); mock keeps the demo working offline.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
|
||||||
import { useMessengerData, useThread, useGroupSettings, type Membership, type UiAttachment, type UiConversation, type UiMember, type UiMessage, type UiPerson } from "@/lib/messenger-api";
|
|
||||||
import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
|
|
||||||
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
|
|
||||||
|
|
||||||
const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
|
||||||
|
|
||||||
/** Renders a message attachment — an inline image thumbnail, or a downloadable file chip. */
|
|
||||||
function AttachmentView({ att }: { att: UiAttachment }) {
|
|
||||||
const getUrl = useDownloadUrl();
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [att.contentRef, att.mimeType, getUrl]);
|
|
||||||
|
|
||||||
if (isImage(att.mimeType)) {
|
|
||||||
return url ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<a href={url} target="_blank" rel="noreferrer"><img src={url} alt="attachment" style={{ maxWidth: 240, maxHeight: 240, borderRadius: 10, display: "block", marginTop: 6, border: "1px solid var(--border)" }} /></a>
|
|
||||||
) : <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 12 }}>Loading image…</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}>
|
|
||||||
<Icon name="paperclip" size={18} />
|
|
||||||
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
|
|
||||||
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialsOf = (name: string) =>
|
|
||||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
|
||||||
const timeOf = (iso?: string) => {
|
|
||||||
if (!iso) return "";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
||||||
};
|
|
||||||
const GROUP_GRAD = "linear-gradient(135deg,#6366f1,#8b5cf6)";
|
|
||||||
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
|
|
||||||
const REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
|
||||||
const inputStyle: CSSProperties = {
|
|
||||||
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
|
|
||||||
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Messenger() {
|
|
||||||
// One shared IIOS socket for the whole panel (live in Shell mode; no-op in mock).
|
|
||||||
return (
|
|
||||||
<MessengerSocketProvider>
|
|
||||||
<MessengerPanel />
|
|
||||||
</MessengerSocketProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessengerPanel() {
|
|
||||||
const m = useMessengerData();
|
|
||||||
const toast = useToast();
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
|
||||||
const [newOpen, setNewOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ((!selected || !m.conversations.some((c) => c.threadId === selected)) && m.conversations[0]) {
|
|
||||||
setSelected(m.conversations[0].threadId);
|
|
||||||
}
|
|
||||||
}, [m.conversations, selected]);
|
|
||||||
|
|
||||||
const current = m.conversations.find((c) => c.threadId === selected) ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="view">
|
|
||||||
<PageHead
|
|
||||||
eyebrow="Communication" title="Messenger" subtitle="Chat with your team and clients — direct or in groups" icon="send"
|
|
||||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New chat</Btn>}
|
|
||||||
/>
|
|
||||||
{!m.live && (
|
|
||||||
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
|
|
||||||
Demo mode — running on mock data. It goes live once the Shell + be-crm are connected.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
|
|
||||||
<aside style={{ width: 296, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
|
|
||||||
{m.loading && <div style={{ padding: 16, color: "var(--muted)" }}>Loading…</div>}
|
|
||||||
{!m.loading && m.conversations.length === 0 && (
|
|
||||||
<div style={{ padding: 16, color: "var(--muted)" }}>No conversations yet. Start a new chat.</div>
|
|
||||||
)}
|
|
||||||
{m.conversations.map((c) => (
|
|
||||||
<ConversationRow key={c.threadId} c={c} active={c.threadId === selected} onClick={() => setSelected(c.threadId)} />
|
|
||||||
))}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
|
||||||
{current ? (
|
|
||||||
<ThreadView key={current.threadId} conv={current} nameOf={m.nameOf} directory={m.directory} onError={(msg) => toast.push({ tone: "error", title: "Message failed", desc: msg })} />
|
|
||||||
) : (
|
|
||||||
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
|
|
||||||
<Icon name="send" size={38} />
|
|
||||||
<p>Select or start a conversation</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewChatModal
|
|
||||||
open={newOpen} onClose={() => setNewOpen(false)} directory={m.directory}
|
|
||||||
onCreate={async (ids, opts) => {
|
|
||||||
try {
|
|
||||||
const id = await m.openConversation(ids, opts);
|
|
||||||
setSelected(id);
|
|
||||||
setNewOpen(false);
|
|
||||||
} catch (e) {
|
|
||||||
toast.push({ tone: "error", title: "Couldn't start chat", desc: (e as Error).message });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConversationRow({ c, active, onClick }: { c: UiConversation; active: boolean; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
display: "flex", gap: 10, alignItems: "center", width: "100%", textAlign: "left",
|
|
||||||
padding: "10px 14px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
|
|
||||||
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar initials={initialsOf(c.title)} size={38} gradient={c.membership === "group" ? GROUP_GRAD : undefined} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
|
|
||||||
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{c.title}</span>
|
|
||||||
<span style={{ color: "var(--muted)", fontSize: 11, flexShrink: 0 }}>{timeOf(c.lastAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
|
|
||||||
<span style={{ color: "var(--muted)", fontSize: 12.5, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{c.lastMessage ?? "No messages yet"}
|
|
||||||
</span>
|
|
||||||
{c.unread > 0 && (
|
|
||||||
<span style={{ background: "var(--orange)", color: "#fff", borderRadius: 999, fontSize: 11, padding: "1px 7px", flexShrink: 0 }}>{c.unread}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThreadView({ conv, nameOf, directory, onError }: { conv: UiConversation; nameOf: (id: string) => string; directory: UiPerson[]; onError: (m: string) => void }) {
|
|
||||||
const t = useThread(conv.threadId);
|
|
||||||
const socket = useMessengerSocket();
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [replyTo, setReplyTo] = useState<UiMessage | null>(null);
|
|
||||||
const [flashId, setFlashId] = useState<string | null>(null);
|
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const msgRefs = useRef<Map<string, HTMLElement>>(new Map());
|
|
||||||
const typingSentAt = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]);
|
|
||||||
|
|
||||||
const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]);
|
|
||||||
const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]);
|
|
||||||
|
|
||||||
// Reply → focus the composer (bug: it didn't focus, forcing a manual click).
|
|
||||||
function startReply(msg: UiMessage) {
|
|
||||||
setReplyTo(msg);
|
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
|
||||||
}
|
|
||||||
// Click a quoted message → scroll to the original and flash it.
|
|
||||||
function jumpTo(id: string) {
|
|
||||||
const el = msgRefs.current.get(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
setFlashId(id);
|
|
||||||
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadAttachment = useUploadAttachment();
|
|
||||||
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
function onDraftChange(v: string) {
|
|
||||||
setDraft(v);
|
|
||||||
const now = Date.now();
|
|
||||||
if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPickFile(file: File | undefined) {
|
|
||||||
if (!file) return;
|
|
||||||
setUploading(true);
|
|
||||||
try { setStaged(await uploadAttachment(file)); }
|
|
||||||
catch (e) { onError((e as Error).message); }
|
|
||||||
finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const text = draft.trim();
|
|
||||||
if ((!text && !staged) || sending) return; // allow an attachment with no text
|
|
||||||
const parent = replyTo?.id;
|
|
||||||
const att = staged;
|
|
||||||
setDraft(""); setReplyTo(null); setStaged(null); setSending(true);
|
|
||||||
try {
|
|
||||||
await t.send(text, {
|
|
||||||
...(parent ? { parentInteractionId: parent } : {}),
|
|
||||||
...(att ? { attachment: { contentRef: att.contentRef, mimeType: att.mimeType, sizeBytes: att.sizeBytes } } : {}),
|
|
||||||
});
|
|
||||||
} catch (e) { setDraft(text); setStaged(att); onError((e as Error).message); }
|
|
||||||
finally { setSending(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
|
|
||||||
const typingLabel = t.typingUserIds.length === 1
|
|
||||||
? `${nameOf(t.typingUserIds[0])} is typing…`
|
|
||||||
: t.typingUserIds.length > 1 ? "Several people are typing…" : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
|
|
||||||
<Avatar initials={initialsOf(conv.title)} size={34} gradient={conv.membership === "group" ? GROUP_GRAD : undefined} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 600 }}>{conv.title}</div>
|
|
||||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>
|
|
||||||
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{conv.membership === "group" && (
|
|
||||||
<button onClick={() => setSettingsOpen(true)} title="Group settings" style={{ ...actionBtnStyle, width: 34, height: 34 }}>
|
|
||||||
<Icon name="settings" size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
{conv.membership === "group" && settingsOpen && (
|
|
||||||
<GroupSettingsModal conv={conv} directory={directory} onClose={() => setSettingsOpen(false)} onError={onError} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: 18, display: "flex", flexDirection: "column", gap: 10, background: "var(--bg)" }}>
|
|
||||||
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading messages…</div>}
|
|
||||||
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages yet — say hello 👋</div>}
|
|
||||||
{t.messages.map((msg) => (
|
|
||||||
<MessageBubble
|
|
||||||
key={msg.id} msg={msg}
|
|
||||||
parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined}
|
|
||||||
seen={msg.id === lastMineId && t.seenIds.has(msg.id)}
|
|
||||||
showStatus={msg.id === lastMineId}
|
|
||||||
flash={flashId === msg.id}
|
|
||||||
registerRef={(el) => { if (el) msgRefs.current.set(msg.id, el); else msgRefs.current.delete(msg.id); }}
|
|
||||||
onReact={(emoji) => t.react(msg.id, emoji)}
|
|
||||||
onReply={() => startReply(msg)}
|
|
||||||
onQuoteClick={jumpTo}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div ref={endRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ minHeight: 18, padding: "0 18px", color: "var(--muted)", fontSize: 12, fontStyle: "italic" }}>{typingLabel}</div>
|
|
||||||
|
|
||||||
{replyTo && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)" }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 11, color: "var(--orange)", fontWeight: 600 }}>Replying to {replyTo.mine ? "yourself" : nameOf(replyTo.senderId ?? "")}</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{replyTo.text}</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setReplyTo(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Cancel reply">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{staged && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)" }}>
|
|
||||||
<Icon name={isImage(staged.mimeType) ? "image" : "file"} size={16} />
|
|
||||||
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>{staged.filename}</span>
|
|
||||||
<span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(staged.sizeBytes)}</span>
|
|
||||||
<button onClick={() => setStaged(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Remove attachment">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)", alignItems: "center" }}>
|
|
||||||
<input ref={fileRef} type="file" hidden onChange={(e) => void onPickFile(e.target.files?.[0])} />
|
|
||||||
<button onClick={() => fileRef.current?.click()} disabled={uploading} title="Attach a file" style={{ ...actionBtnStyle, width: 38, height: 38, flexShrink: 0, opacity: uploading ? 0.5 : 1 }}>
|
|
||||||
{uploading ? "…" : "📎"}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={draft} onChange={(e) => onDraftChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
|
|
||||||
placeholder="Type a message…" style={inputStyle}
|
|
||||||
/>
|
|
||||||
<Btn icon="send" onClick={() => void submit()} disabled={sending || (!draft.trim() && !staged)}>Send</Btn>
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageBubble({
|
|
||||||
msg, parent, seen, showStatus, flash, registerRef, onReact, onReply, onQuoteClick,
|
|
||||||
}: {
|
|
||||||
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; flash?: boolean;
|
|
||||||
registerRef?: (el: HTMLElement | null) => void;
|
|
||||||
onReact: (emoji: string) => void; onReply: () => void; onQuoteClick?: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [hover, setHover] = useState(false);
|
|
||||||
const [picker, setPicker] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={registerRef}
|
|
||||||
onMouseEnter={() => setHover(true)}
|
|
||||||
onMouseLeave={() => { setHover(false); setPicker(false); }}
|
|
||||||
style={{
|
|
||||||
alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column",
|
|
||||||
alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative",
|
|
||||||
borderRadius: 14, padding: 2, transition: "background 0.4s",
|
|
||||||
background: flash ? "rgba(253,169,19,0.22)" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parent && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => parent.id && onQuoteClick?.(parent.id)}
|
|
||||||
title="Go to message"
|
|
||||||
style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", border: "none", borderLeftWidth: 3, fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: "pointer", textAlign: "left" }}
|
|
||||||
>
|
|
||||||
<span style={{ opacity: 0.8 }}>↩ {parent.text}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
|
|
||||||
{(msg.text || !msg.attachment) && (
|
|
||||||
<div style={{
|
|
||||||
background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)",
|
|
||||||
padding: "8px 12px", borderRadius: 14,
|
|
||||||
borderBottomRightRadius: msg.mine ? 4 : 14, borderBottomLeftRadius: msg.mine ? 14 : 4,
|
|
||||||
border: msg.mine ? "none" : "1px solid var(--border)",
|
|
||||||
whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14,
|
|
||||||
}}>
|
|
||||||
{msg.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hover && (
|
|
||||||
<div style={{ display: "flex", gap: 2, position: "relative" }}>
|
|
||||||
<button onClick={() => setPicker((p) => !p)} title="React" style={actionBtnStyle}>🙂</button>
|
|
||||||
<button onClick={onReply} title="Reply" style={actionBtnStyle}>↩</button>
|
|
||||||
{picker && (
|
|
||||||
<div style={{ position: "absolute", bottom: "100%", [msg.mine ? "right" : "left"]: 0, marginBottom: 4, display: "flex", gap: 2, padding: 4, borderRadius: 999, background: "var(--panel)", border: "1px solid var(--border)", boxShadow: "0 6px 20px rgba(0,0,0,0.35)", zIndex: 5 }}>
|
|
||||||
{REACTION_EMOJIS.map((e) => (
|
|
||||||
<button key={e} onClick={() => { onReact(e); setPicker(false); }} style={{ ...actionBtnStyle, fontSize: 16 }}>{e}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{msg.attachment && (
|
|
||||||
<div style={{ marginTop: 4, display: "flex", justifyContent: msg.mine ? "flex-end" : "flex-start" }}>
|
|
||||||
<AttachmentView att={msg.attachment} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg.reactions && msg.reactions.length > 0 && (
|
|
||||||
<div style={{ display: "flex", gap: 4, marginTop: 3, flexWrap: "wrap" }}>
|
|
||||||
{msg.reactions.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r.emoji} onClick={() => onReact(r.emoji)}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex", alignItems: "center", gap: 3, padding: "1px 7px", borderRadius: 999, fontSize: 12, cursor: "pointer",
|
|
||||||
background: r.mine ? "rgba(253,169,19,0.18)" : "var(--panel-2)",
|
|
||||||
border: `1px solid ${r.mine ? "var(--orange)" : "var(--border)"}`, color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{r.emoji}</span><span style={{ color: "var(--muted)" }}>{r.count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ fontSize: 10.5, color: "var(--muted)", marginTop: 2 }}>
|
|
||||||
{timeOf(msg.at)}{showStatus && msg.mine ? ` · ${seen ? "Seen" : "Sent"}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionBtnStyle: CSSProperties = {
|
|
||||||
background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 8,
|
|
||||||
width: 26, height: 26, display: "grid", placeItems: "center", cursor: "pointer", fontSize: 13, color: "var(--text)", padding: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function NewChatModal({
|
|
||||||
open, onClose, directory, onCreate,
|
|
||||||
}: {
|
|
||||||
open: boolean; onClose: () => void; directory: UiPerson[];
|
|
||||||
onCreate: (ids: string[], opts: { membership: Membership; subject?: string }) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [picked, setPicked] = useState<string[]>([]);
|
|
||||||
const [subject, setSubject] = useState("");
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { if (!open) { setPicked([]); setSubject(""); setQ(""); setBusy(false); } }, [open]);
|
|
||||||
|
|
||||||
const membership: Membership = picked.length > 1 ? "group" : "dm";
|
|
||||||
const filtered = directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
|
||||||
const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : [...l, id]));
|
|
||||||
|
|
||||||
async function create() {
|
|
||||||
if (!picked.length || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
await onCreate(picked, { membership, ...(membership === "group" && subject.trim() ? { subject: subject.trim() } : {}) });
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open} onClose={onClose} title="New conversation"
|
|
||||||
subtitle={membership === "group" ? "Group chat" : "Direct message"} icon="send"
|
|
||||||
footer={<>
|
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
||||||
<Btn icon="send" onClick={() => void create()} disabled={!picked.length || busy}>{busy ? "Starting…" : "Start chat"}</Btn>
|
|
||||||
</>}
|
|
||||||
>
|
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 10 }} />
|
|
||||||
{membership === "group" && (
|
|
||||||
<Field label="Group name (optional)">
|
|
||||||
<input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Storm response" style={inputStyle} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
<div style={{ maxHeight: 320, overflowY: "auto", marginTop: 8, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 10 }}>No people found.</div>}
|
|
||||||
{filtered.map((p) => (
|
|
||||||
<label key={p.id} style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 10, cursor: "pointer",
|
|
||||||
background: picked.includes(p.id) ? "var(--panel-2)" : "transparent",
|
|
||||||
}}>
|
|
||||||
<input type="checkbox" checked={picked.includes(p.id)} onChange={() => toggle(p.id)} />
|
|
||||||
<Avatar initials={initialsOf(p.name)} size={30} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
|
||||||
<span style={{ flex: 1 }}>{p.name}</span>
|
|
||||||
<Pill tone="muted">{p.kind}</Pill>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Group settings: rename, member list with roles, add/remove — admin-gated (IIOS/OPA re-enforces). */
|
|
||||||
function GroupSettingsModal({ conv, directory, onClose, onError }: {
|
|
||||||
conv: UiConversation; directory: UiPerson[]; onClose: () => void; onError: (m: string) => void;
|
|
||||||
}) {
|
|
||||||
const g = useGroupSettings(conv.threadId);
|
|
||||||
const [name, setName] = useState(conv.subject ?? "");
|
|
||||||
const [savingName, setSavingName] = useState(false);
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => { setName(conv.subject ?? ""); }, [conv.subject]);
|
|
||||||
|
|
||||||
const memberIds = useMemo(() => new Set(g.members.map((m) => m.userId)), [g.members]);
|
|
||||||
const nameChanged = name.trim() && name.trim() !== (conv.subject ?? "").trim();
|
|
||||||
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
|
||||||
|
|
||||||
async function saveName() {
|
|
||||||
if (!nameChanged || savingName) return;
|
|
||||||
setSavingName(true);
|
|
||||||
try { await g.rename(name.trim()); }
|
|
||||||
catch (e) { onError((e as Error).message); }
|
|
||||||
finally { setSavingName(false); }
|
|
||||||
}
|
|
||||||
async function add(userId: string) {
|
|
||||||
setPendingId(userId);
|
|
||||||
try { await g.addMember(userId); }
|
|
||||||
catch (e) { onError((e as Error).message); }
|
|
||||||
finally { setPendingId(null); }
|
|
||||||
}
|
|
||||||
async function remove(userId: string) {
|
|
||||||
setPendingId(userId);
|
|
||||||
try { await g.removeMember(userId); }
|
|
||||||
catch (e) { onError((e as Error).message); }
|
|
||||||
finally { setPendingId(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open onClose={onClose} title="Group settings" subtitle={conv.title} icon="settings"
|
|
||||||
footer={<Btn variant="ghost" onClick={onClose}>Done</Btn>}
|
|
||||||
>
|
|
||||||
<Field label="Group name">
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} disabled={!g.isAdmin}
|
|
||||||
placeholder="Group name" style={{ ...inputStyle, opacity: g.isAdmin ? 1 : 0.6 }} />
|
|
||||||
{g.isAdmin && <Btn onClick={() => void saveName()} disabled={!nameChanged || savingName}>{savingName ? "…" : "Save"}</Btn>}
|
|
||||||
</div>
|
|
||||||
{!g.isAdmin && <div style={{ color: "var(--muted)", fontSize: 12, marginTop: 4 }}>Only a group admin can rename the group.</div>}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={`Members (${g.members.length})`}>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 200, overflowY: "auto" }}>
|
|
||||||
{g.loading && g.members.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>Loading…</div>}
|
|
||||||
{g.members.map((mem: UiMember) => (
|
|
||||||
<div key={mem.userId} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10 }}>
|
|
||||||
<Avatar initials={initialsOf(mem.displayName)} size={28} gradient={GROUP_GRAD} />
|
|
||||||
<span style={{ flex: 1 }}>{mem.displayName}</span>
|
|
||||||
{mem.role === "ADMIN" && <Pill tone="purple">admin</Pill>}
|
|
||||||
{g.isAdmin && mem.role !== "ADMIN" && (
|
|
||||||
<button onClick={() => void remove(mem.userId)} disabled={pendingId === mem.userId} title="Remove"
|
|
||||||
style={{ ...actionBtnStyle, width: 28, height: 28 }}><Icon name="trash" size={15} /></button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{g.isAdmin && (
|
|
||||||
<Field label="Add member">
|
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 180, overflowY: "auto" }}>
|
|
||||||
{addable.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No one to add.</div>}
|
|
||||||
{addable.map((p) => (
|
|
||||||
<button key={p.id} onClick={() => void add(p.id)} disabled={pendingId === p.id}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: "transparent", border: "none", color: "var(--text)", textAlign: "left" }}>
|
|
||||||
<Avatar initials={initialsOf(p.name)} size={28} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
|
||||||
<span style={{ flex: 1 }}>{p.name}</span>
|
|
||||||
<Icon name="plus" size={16} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Topbar bell → offline-notification control. Click opens a small popover to enable/disable Web Push
|
||||||
|
// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't
|
||||||
|
// available (demo mode, or a browser without ServiceWorker/PushManager).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import { usePushNotifications } from "@/lib/push-notifications";
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const push = usePushNotifications();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!push.supported) return null;
|
||||||
|
|
||||||
|
const denied = push.permission === "denied";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
className="ic-btn"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<Icon name="bell" size={18} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 9,
|
||||||
|
right: 10,
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 99,
|
||||||
|
background: push.subscribed ? "var(--orange)" : "var(--border)",
|
||||||
|
border: "2px solid var(--panel)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
|
||||||
|
<div className="tm-menu-pop" role="menu" style={{ width: 260, padding: 14 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}>
|
||||||
|
{push.subscribed
|
||||||
|
? "You'll get push notifications for new direct messages and mentions, even when this tab is closed."
|
||||||
|
: "Get notified about direct messages and mentions when the CRM isn't open."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{denied ? (
|
||||||
|
<p style={{ fontSize: 12, color: "var(--danger, #c0392b)", margin: 0 }}>
|
||||||
|
Notifications are blocked in your browser settings. Allow them for this site, then try again.
|
||||||
|
</p>
|
||||||
|
) : push.subscribed ? (
|
||||||
|
<button className="ds-btn v-ghost full" disabled={push.busy} onClick={() => push.disable()}>
|
||||||
|
{push.busy ? "Turning off…" : "Turn off notifications"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="ds-btn v-primary full" disabled={push.busy} onClick={() => push.enable()}>
|
||||||
|
{push.busy ? "Enabling…" : "Enable notifications"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// App-wide in-app notifications. Uses the shared dashboard socket to watch activity across ALL of
|
||||||
|
// the user's threads (via the adapter's subscribeActivity) and shows a clickable toast when a new
|
||||||
|
// message arrives — unless you're already on the Messenger tab (you'd see it live there). Clicking
|
||||||
|
// deep-links to the conversation. Renders nothing.
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import type { MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||||
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
import { useRealtime } from "@/lib/realtime";
|
||||||
|
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||||
|
import { useToast } from "./ui";
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
export function NotificationCenter({ active, onNavigate }: { active: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||||
|
const socket = useRealtime();
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const me = user?.id;
|
||||||
|
|
||||||
|
// Keep the current tab readable inside the (stable) subscription callback.
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
|
const adapter = useMemo<MessagingAdapter | null>(
|
||||||
|
() => (me && socket ? new CrmMessagingAdapter(sdk as unknown as DataDoor, me, socket) : null),
|
||||||
|
[sdk, me, socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SHELL || !adapter?.subscribeActivity) return;
|
||||||
|
return adapter.subscribeActivity(({ threadId, message }) => {
|
||||||
|
if (message.actorId === me) return; // never notify me about my own message
|
||||||
|
if (activeRef.current === "messenger") return; // already watching chat live
|
||||||
|
toast.push({
|
||||||
|
tone: "info",
|
||||||
|
title: "New message",
|
||||||
|
desc: message.text?.slice(0, 90) || "You have a new message",
|
||||||
|
onClick: () => onNavigate("messenger", threadId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [adapter, me, toast, onNavigate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Org Settings → Integrations. Today: SMS (Twilio) — a tenant
|
||||||
|
// brings its OWN Twilio credentials, which be-crm seals in IIOS
|
||||||
|
// (per-scope) and resolves at send time. The auth token is
|
||||||
|
// write-only: sealed in IIOS, never read back, so status shows
|
||||||
|
// only masked hints (from-number + SID last-4). Email (SMTP) is
|
||||||
|
// the next provider on the same generic credential registry.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui";
|
||||||
|
import { useSmsSettings } from "@/lib/sms-settings-api";
|
||||||
|
import { useSmtpSettings } from "@/lib/smtp-settings-api";
|
||||||
|
|
||||||
|
const SID_RE = /^AC[0-9a-fA-F]{32}$/;
|
||||||
|
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
||||||
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
return (
|
||||||
|
<div className="view">
|
||||||
|
<PageHead
|
||||||
|
eyebrow="Configuration"
|
||||||
|
title="Org Settings"
|
||||||
|
subtitle="Integrations and workspace configuration"
|
||||||
|
icon="settings"
|
||||||
|
/>
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3 className="settings-section-title">Integrations</h3>
|
||||||
|
<div className="settings-grid">
|
||||||
|
<TwilioCard />
|
||||||
|
<SmtpCard />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TwilioCard() {
|
||||||
|
const toast = useToast();
|
||||||
|
const { status, loading, live, configure } = useSmsSettings();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [accountSid, setAccountSid] = useState("");
|
||||||
|
const [authToken, setAuthToken] = useState("");
|
||||||
|
const [fromNumber, setFromNumber] = useState("");
|
||||||
|
const [errors, setErrors] = useState<{ accountSid?: string; authToken?: string; fromNumber?: string }>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const showForm = editing || (!loading && !status.configured);
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const e: typeof errors = {};
|
||||||
|
if (!SID_RE.test(accountSid.trim())) e.accountSid = "Must be a Twilio Account SID (AC + 32 hex chars).";
|
||||||
|
if (!authToken.trim()) e.authToken = "Auth token is required.";
|
||||||
|
if (!E164_RE.test(fromNumber.trim())) e.fromNumber = "Must be E.164, e.g. +15551234567.";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!validate()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await configure({ accountSid: accountSid.trim(), authToken: authToken.trim(), fromNumber: fromNumber.trim() });
|
||||||
|
toast.push({ tone: "success", title: "Twilio connected", desc: "Your SMS credentials are saved and encrypted." });
|
||||||
|
setAccountSid(""); setAuthToken(""); setFromNumber(""); setErrors({}); setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.push({ tone: "error", title: "Couldn't save credentials", desc: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-card-head">
|
||||||
|
<span className="settings-card-ic" aria-hidden="true"><Icon name="send" size={20} /></span>
|
||||||
|
<div className="settings-card-titles">
|
||||||
|
<div className="settings-card-name">
|
||||||
|
SMS <span className="settings-card-sub">· Twilio</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card-desc">Send texts from your own Twilio number.</div>
|
||||||
|
</div>
|
||||||
|
{status.configured
|
||||||
|
? <Pill tone="green">Connected</Pill>
|
||||||
|
: <Pill tone="muted">Not connected</Pill>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.configured && !editing ? (
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<dl className="settings-kv">
|
||||||
|
<div><dt>From number</dt><dd>{status.fromNumber ?? "—"}</dd></div>
|
||||||
|
<div><dt>Account SID</dt><dd>{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}</dd></div>
|
||||||
|
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<Field label="Account SID" required error={errors.accountSid} hint="Twilio Console → Account Info.">
|
||||||
|
<input className="ds-input" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Auth Token" required error={errors.authToken} hint="Encrypted on save and never shown again.">
|
||||||
|
<input className="ds-input" type="password" value={authToken} onChange={(e) => setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="From number" required error={errors.fromNumber} hint="A Twilio number in E.164 format.">
|
||||||
|
<input className="ds-input" value={fromNumber} onChange={(e) => setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<div className="settings-card-actions">
|
||||||
|
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"}</Btn>
|
||||||
|
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!live ? <div className="settings-card-note">Demo mode — credentials are stored locally and not sent to Twilio.</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmtpCard() {
|
||||||
|
const toast = useToast();
|
||||||
|
const { status, loading, live, configure } = useSmtpSettings();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [port, setPort] = useState("587");
|
||||||
|
const [secure, setSecure] = useState(false);
|
||||||
|
const [user, setUser] = useState("");
|
||||||
|
const [pass, setPass] = useState("");
|
||||||
|
const [fromEmail, setFromEmail] = useState("");
|
||||||
|
const [fromName, setFromName] = useState("");
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const showForm = editing || (!loading && !status.configured);
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const e: Record<string, string> = {};
|
||||||
|
if (!host.trim()) e.host = "SMTP host is required.";
|
||||||
|
const p = Number(port);
|
||||||
|
if (!Number.isInteger(p) || p < 1 || p > 65535) e.port = "Port must be 1–65535.";
|
||||||
|
if (!user.trim()) e.user = "Username is required.";
|
||||||
|
if (!pass.trim()) e.pass = "Password is required.";
|
||||||
|
if (!EMAIL_RE.test(fromEmail.trim())) e.fromEmail = "A valid from-address is required.";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!validate()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await configure({ host: host.trim(), port: Number(port), secure, user: user.trim(), pass: pass.trim(), fromEmail: fromEmail.trim(), ...(fromName.trim() ? { fromName: fromName.trim() } : {}) });
|
||||||
|
toast.push({ tone: "success", title: "SMTP connected", desc: "Outbound email now sends from your server." });
|
||||||
|
setHost(""); setPort("587"); setSecure(false); setUser(""); setPass(""); setFromEmail(""); setFromName(""); setErrors({}); setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.push({ tone: "error", title: "Couldn't save SMTP settings", desc: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-card-head">
|
||||||
|
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
|
||||||
|
<div className="settings-card-titles">
|
||||||
|
<div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div>
|
||||||
|
<div className="settings-card-desc">Send external email from your own mail server.</div>
|
||||||
|
</div>
|
||||||
|
{status.configured ? <Pill tone="green">Connected</Pill> : <Pill tone="muted">Not connected</Pill>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.configured && !editing ? (
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<dl className="settings-kv">
|
||||||
|
<div><dt>From</dt><dd>{status.fromName ? `${status.fromName} · ` : ""}{status.fromEmail ?? "—"}</dd></div>
|
||||||
|
<div><dt>Server</dt><dd>{status.host ?? "—"}{status.port ? `:${status.port}` : ""}</dd></div>
|
||||||
|
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<Field label="SMTP host" required error={errors.host} hint="e.g. smtp.sendgrid.net or your mail server.">
|
||||||
|
<input className="ds-input" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Port" required error={errors.port} hint="587 (STARTTLS) or 465 (SSL).">
|
||||||
|
<input className="ds-input" value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<label className="settings-check">
|
||||||
|
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} /> Use SSL/TLS (port 465)
|
||||||
|
</label>
|
||||||
|
<Field label="Username" required error={errors.user} hint="Often your email or an API key.">
|
||||||
|
<input className="ds-input" value={user} onChange={(e) => setUser(e.target.value)} placeholder="apikey / user@example.com" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Password" required error={errors.pass} hint="Encrypted on save and never shown again.">
|
||||||
|
<input className="ds-input" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="••••••••••••" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="From address" required error={errors.fromEmail}>
|
||||||
|
<input className="ds-input" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="From name" hint="Optional display name on outgoing mail.">
|
||||||
|
<input className="ds-input" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="Acme Roofing" autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<div className="settings-card-actions">
|
||||||
|
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect SMTP"}</Btn>
|
||||||
|
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!live ? <div className="settings-card-note">Demo mode — credentials are stored locally and no email is sent.</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,7 +80,15 @@ 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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,15 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
|
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
|
||||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
import { Icon } from "./ui";
|
import { GlobalSearch } from "./global-search";
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
import { user } from "./account-data";
|
import { user } from "./account-data";
|
||||||
|
|
||||||
function initialsOf(name: string): string {
|
function initialsOf(name: string): string {
|
||||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
|
export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||||
// When signed in through the Shell, show the real identity from the App Context
|
// When signed in through the Shell, show the real identity from the App Context
|
||||||
// Envelope; otherwise fall back to the static demo user.
|
// Envelope; otherwise fall back to the static demo user.
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,14 +36,11 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
|
|||||||
<p>{subtitle}</p>
|
<p>{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="top-actions">
|
<div className="top-actions">
|
||||||
<button className="ic-btn" aria-label="Search"><Icon name="search" size={18} /></button>
|
<GlobalSearch onNavigate={onNavigate} />
|
||||||
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
|
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
|
||||||
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
|
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
|
<NotificationBell />
|
||||||
<Icon name="bell" size={18} />
|
|
||||||
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
|
||||||
</button>
|
|
||||||
<div className="top-user-wrap" style={{ position: "relative" }}>
|
<div className="top-user-wrap" style={{ position: "relative" }}>
|
||||||
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
|
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
|
||||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
||||||
|
|||||||
@@ -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, type LucideIcon,
|
UsersRound, Image as ImageIcon, Images, File, FolderOpen, Download,
|
||||||
|
Video, Play, LayoutGrid, ZoomIn, type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
@@ -50,6 +51,10 @@ const ICONS: Record<string, LucideIcon> = {
|
|||||||
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,
|
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 }) {
|
||||||
@@ -260,7 +265,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
|
|||||||
/* Toast */
|
/* Toast */
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string };
|
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string; onClick?: () => void };
|
||||||
type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
|
type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
|
||||||
const ToastContext = createContext<ToastCtx | null>(null);
|
const ToastContext = createContext<ToastCtx | null>(null);
|
||||||
|
|
||||||
@@ -283,9 +288,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
<div className="ds-toasts">
|
<div className="ds-toasts">
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<div key={t.id} className={`ds-toast tone-${t.tone}`}>
|
<div key={t.id} className={`ds-toast tone-${t.tone}${t.onClick ? " is-clickable" : ""}`}>
|
||||||
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
|
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
|
||||||
<div className="ds-toast-body">
|
<div
|
||||||
|
className="ds-toast-body"
|
||||||
|
{...(t.onClick ? { role: "button", tabIndex: 0, onClick: () => { t.onClick?.(); setItems((s) => s.filter((x) => x.id !== t.id)); } } : {})}
|
||||||
|
>
|
||||||
<div className="ds-toast-title">{t.title}</div>
|
<div className="ds-toast-title">{t.title}</div>
|
||||||
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
|
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// The CRM's InboxAdapter — the SDK <Inbox> rendered over the be-crm data door
|
||||||
|
// (crm.inbox.* + crm.mail.*). Folds mail threads into the unified inbox exactly as the old
|
||||||
|
// inbox-api did; the CRM keeps auth/tenancy server-side.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
InboxAdapter,
|
||||||
|
InboxItem,
|
||||||
|
InboxState,
|
||||||
|
MailAttachment,
|
||||||
|
MailMessage,
|
||||||
|
MailPerson,
|
||||||
|
} from "@insignia/iios-messaging-ui";
|
||||||
|
import type { DataDoor } from "./crm-messaging-adapter";
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
|
||||||
|
|
||||||
|
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
|
||||||
|
const EXT_MIME: Record<string, string> = {
|
||||||
|
md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html", txt: "text/plain", csv: "text/csv",
|
||||||
|
};
|
||||||
|
function mimeForFile(file: File): string {
|
||||||
|
if (file.type) return file.type;
|
||||||
|
const ext = file.name.toLowerCase().split(".").pop() ?? "";
|
||||||
|
return EXT_MIME[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboxItemDTO {
|
||||||
|
id: string; kind: string; state: InboxState; title: string; summary?: string; priority: string; threadId?: string; createdAt: string;
|
||||||
|
}
|
||||||
|
interface MailThreadDTO { threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string }
|
||||||
|
interface MailMessageDTO {
|
||||||
|
interactionId: string; actorId: string | null; kind: string; occurredAt: string;
|
||||||
|
html: string | null; text: string | null;
|
||||||
|
attachment: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null;
|
||||||
|
}
|
||||||
|
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
||||||
|
|
||||||
|
const escapeHtml = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
export class CrmInboxAdapter implements InboxAdapter {
|
||||||
|
constructor(private readonly sdk: DataDoor) {}
|
||||||
|
|
||||||
|
async listInbox(state?: InboxState): Promise<InboxItem[]> {
|
||||||
|
const showMail = !state || state === "OPEN";
|
||||||
|
const [items, mail] = await Promise.all([
|
||||||
|
this.sdk.query<InboxItemDTO[]>("crm.inbox.list", state ? { state } : {}),
|
||||||
|
showMail ? this.sdk.query<MailThreadDTO[]>("crm.mail.list", {}) : Promise.resolve([] as MailThreadDTO[]),
|
||||||
|
]);
|
||||||
|
const mailItems: InboxItem[] = mail.map((t) => ({
|
||||||
|
id: `mail:${t.threadId}`,
|
||||||
|
kind: "MAIL",
|
||||||
|
state: "OPEN",
|
||||||
|
title: t.subject || "(no subject)",
|
||||||
|
...(t.lastMessage ? { summary: t.lastMessage } : {}),
|
||||||
|
priority: t.unread > 0 ? "HIGH" : "LOW",
|
||||||
|
threadId: t.threadId,
|
||||||
|
createdAt: t.lastAt ?? "",
|
||||||
|
}));
|
||||||
|
return [...mailItems, ...items].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
async transition(id: string, state: InboxState): Promise<void> {
|
||||||
|
await this.sdk.command("crm.inbox.transition", { id, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
async mailHistory(threadId: string): Promise<MailMessage[]> {
|
||||||
|
const rows = await this.sdk.query<MailMessageDTO[]>("crm.mail.history", { threadId });
|
||||||
|
return rows.map((m) => ({
|
||||||
|
id: m.interactionId,
|
||||||
|
actorId: m.actorId,
|
||||||
|
kind: m.kind,
|
||||||
|
at: m.occurredAt,
|
||||||
|
html: m.html,
|
||||||
|
text: m.text,
|
||||||
|
attachment: m.attachment,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
|
||||||
|
await this.sdk.command("crm.mail.reply", {
|
||||||
|
threadId,
|
||||||
|
content,
|
||||||
|
...(attachment ? { attachment: { filename: attachment.filename ?? "attachment", contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes } } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAttachment(file: File): Promise<MailAttachment> {
|
||||||
|
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
|
||||||
|
const mime = mimeForFile(file);
|
||||||
|
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}).`);
|
||||||
|
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAttachment(attachment: MailAttachment): Promise<string> {
|
||||||
|
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", {
|
||||||
|
contentRef: attachment.contentRef,
|
||||||
|
...(attachment.mimeType ? { mime: attachment.mimeType } : {}),
|
||||||
|
});
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async directory(): Promise<MailPerson[]> {
|
||||||
|
const rows = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
||||||
|
return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||||
|
await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||||
|
await this.sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentsVar(attachments?: MailAttachment[]): { attachments?: Array<{ filename: string; contentRef: string; mimeType: string; sizeBytes: number }> } {
|
||||||
|
if (!attachments || attachments.length === 0) return {};
|
||||||
|
return { attachments: attachments.map((a) => ({ filename: a.filename ?? "attachment", contentRef: a.contentRef, mimeType: a.mimeType, sizeBytes: a.sizeBytes })) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
// The CRM's implementation of the SDK's MessagingAdapter. HYBRID transport:
|
||||||
|
// • BFF (appshell crm.messenger.*) for the conversation list, thread creation, and directory
|
||||||
|
// — these need server-side tenancy/auth.
|
||||||
|
// • IIOS MessageSocket (delegated token from crm.messenger.realtime) for everything live:
|
||||||
|
// history+join, send, typing, read receipts, reactions.
|
||||||
|
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Attachment,
|
||||||
|
ChannelSummary,
|
||||||
|
ChannelVisibility,
|
||||||
|
Conversation,
|
||||||
|
CreateChannelInput,
|
||||||
|
Membership,
|
||||||
|
Message,
|
||||||
|
MessageEvent,
|
||||||
|
MessagingAdapter,
|
||||||
|
Person,
|
||||||
|
Reaction,
|
||||||
|
SendOpts,
|
||||||
|
Unsubscribe,
|
||||||
|
} from "@insignia/iios-messaging-ui";
|
||||||
|
import type { MessageSocket, Message as KernelMessage } from "@insignia/iios-kernel-client";
|
||||||
|
|
||||||
|
/** The imperative appshell data door (useAppShell().sdk). Typed structurally, not to its class. */
|
||||||
|
export interface DataDoor {
|
||||||
|
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||||
|
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
||||||
|
interface ConversationDTO {
|
||||||
|
threadId: string; subject: string | null; membership: Membership | null;
|
||||||
|
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||||
|
}
|
||||||
|
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 REACTION = "reaction";
|
||||||
|
|
||||||
|
interface Poll { seen: Set<string>; primed: boolean; timer: ReturnType<typeof setInterval> | null }
|
||||||
|
|
||||||
|
export class CrmMessagingAdapter implements MessagingAdapter {
|
||||||
|
private names: Map<string, string> | null = null;
|
||||||
|
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
||||||
|
private readonly polls = new Map<string, Poll>();
|
||||||
|
private readonly joined = new Set<string>();
|
||||||
|
/** Cross-thread activity listeners (live unread + in-app notifications). */
|
||||||
|
private readonly activity = new Set<(e: { threadId: string; message: Message }) => void>();
|
||||||
|
/** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */
|
||||||
|
private readonly reactions = new Map<string, Map<string, Set<string>>>();
|
||||||
|
|
||||||
|
/** Only present with a socket — the UI hides the reaction affordance without it. */
|
||||||
|
react?: (threadId: string, messageId: string, emoji: string) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sdk: DataDoor,
|
||||||
|
private readonly me: string,
|
||||||
|
private readonly socket?: MessageSocket,
|
||||||
|
) {
|
||||||
|
if (socket) {
|
||||||
|
socket.on("message", (m) => {
|
||||||
|
this.ingestReactions(m);
|
||||||
|
void this.toKernelMessage(m).then((message) => {
|
||||||
|
this.emit(m.threadId, { kind: "message", message });
|
||||||
|
for (const cb of this.activity) cb({ threadId: m.threadId, message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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.
|
||||||
|
socket.on("receipt", (e) => this.broadcast({ kind: "receipt", messageId: e.interactionId, actorId: e.actorId }));
|
||||||
|
socket.on("annotation", (e) => {
|
||||||
|
if (e.type !== REACTION) return;
|
||||||
|
this.setReactionUsers(e.interactionId, e.value, e.users);
|
||||||
|
this.emit(e.threadId, { kind: "reaction", messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
|
||||||
|
});
|
||||||
|
this.react = async (threadId, messageId, emoji) => {
|
||||||
|
await socket.react(threadId, messageId, emoji);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentActorId(): string {
|
||||||
|
return this.me;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report the foregrounded thread to IIOS presence (suppresses push for what you're viewing). */
|
||||||
|
setFocus(threadId: string | null): void {
|
||||||
|
this.socket?.focus(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire `cb` for every incoming message across ALL the caller's threads (live unread + toasts). */
|
||||||
|
subscribeActivity(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe {
|
||||||
|
this.activity.add(cb);
|
||||||
|
void this.joinAllThreads();
|
||||||
|
return () => {
|
||||||
|
this.activity.delete(cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join every thread the user belongs to so their messages arrive over the socket, not just the
|
||||||
|
* open one. Idempotent (the `joined` set guards re-joins). */
|
||||||
|
private async joinAllThreads(): Promise<void> {
|
||||||
|
if (!this.socket) return;
|
||||||
|
try {
|
||||||
|
const convs = await this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {});
|
||||||
|
for (const c of convs) {
|
||||||
|
if (!this.joined.has(c.threadId)) {
|
||||||
|
this.joined.add(c.threadId);
|
||||||
|
void this.socket.openThread(c.threadId).catch(() => this.joined.delete(c.threadId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort — activity just won't cover un-joined threads */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listConversations(): Promise<Conversation[]> {
|
||||||
|
const [convs, names] = await Promise.all([
|
||||||
|
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
|
||||||
|
this.directoryMap(),
|
||||||
|
]);
|
||||||
|
return convs.map((c) => this.toConversation(c, names));
|
||||||
|
}
|
||||||
|
|
||||||
|
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||||
|
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
|
||||||
|
participantIds: p.participantIds,
|
||||||
|
...(p.membership ? { membership: p.membership } : {}),
|
||||||
|
...(p.subject ? { subject: p.subject } : {}),
|
||||||
|
});
|
||||||
|
return { threadId: res.threadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async history(threadId: string): Promise<Message[]> {
|
||||||
|
if (this.socket) {
|
||||||
|
const res = await this.socket.openThread(threadId); // joins so live events flow
|
||||||
|
this.joined.add(threadId);
|
||||||
|
return Promise.all(
|
||||||
|
res.history.map((m) => {
|
||||||
|
this.ingestReactions(m);
|
||||||
|
return this.toKernelMessage(m);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||||
|
return Promise.all(msgs.map((m) => this.toDtoMessage(m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||||
|
const att = opts?.attachment;
|
||||||
|
if (this.socket) {
|
||||||
|
const sendOpts = {
|
||||||
|
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||||
|
...(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 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 msg = this.fromDto(m);
|
||||||
|
this.polls.get(threadId)?.seen.add(msg.id);
|
||||||
|
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 {
|
||||||
|
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
|
||||||
|
this.listeners.get(threadId)!.add(cb);
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
if (!this.joined.has(threadId)) {
|
||||||
|
this.joined.add(threadId);
|
||||||
|
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.startPoll(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const set = this.listeners.get(threadId);
|
||||||
|
set?.delete(cb);
|
||||||
|
if (set && set.size === 0) {
|
||||||
|
this.listeners.delete(threadId);
|
||||||
|
const poll = this.polls.get(threadId);
|
||||||
|
if (poll?.timer) clearInterval(poll.timer);
|
||||||
|
this.polls.delete(threadId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTyping(threadId: string): void {
|
||||||
|
this.socket?.typing(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(threadId: string, messageId: string): Promise<void> {
|
||||||
|
if (this.socket) await this.socket.markRead(threadId, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── channels + members (BFF, except join which is a governed socket self-join) ──
|
||||||
|
async browseChannels(): Promise<ChannelSummary[]> {
|
||||||
|
const rows = await this.sdk.query<Array<{ threadId: string; name: string; topic: string | null; visibility: string; memberCount: number; joined: boolean }>>(
|
||||||
|
"crm.messenger.channel.browse",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return rows.map((c) => ({
|
||||||
|
threadId: c.threadId,
|
||||||
|
name: c.name,
|
||||||
|
topic: c.topic,
|
||||||
|
visibility: (c.visibility === "private" ? "private" : "public") as ChannelVisibility,
|
||||||
|
memberCount: c.memberCount,
|
||||||
|
joined: c.joined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
|
||||||
|
return this.sdk.command<{ threadId: string }>("crm.messenger.channel.create", {
|
||||||
|
name: input.name,
|
||||||
|
...(input.topic ? { topic: input.topic } : {}),
|
||||||
|
visibility: input.visibility,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinChannel(threadId: string): Promise<void> {
|
||||||
|
// Governed public self-join over the socket (the BFF has no join verb; OPA enforces it).
|
||||||
|
if (!this.socket) throw new Error("joining a channel needs a live connection");
|
||||||
|
await this.socket.openThread(threadId);
|
||||||
|
this.joined.add(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveChannel(threadId: string): Promise<void> {
|
||||||
|
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[]> {
|
||||||
|
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: r.role === "CUSTOMER" ? "customer" : "staff" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── polling fallback (no socket) ───────────────────────────────
|
||||||
|
private startPoll(threadId: string): void {
|
||||||
|
if (this.polls.has(threadId)) return;
|
||||||
|
const poll: Poll = { seen: new Set(), primed: false, timer: null };
|
||||||
|
this.polls.set(threadId, poll);
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (!this.polls.has(threadId)) return;
|
||||||
|
try {
|
||||||
|
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||||
|
for (const m of msgs) {
|
||||||
|
if (poll.seen.has(m.interactionId)) continue;
|
||||||
|
poll.seen.add(m.interactionId);
|
||||||
|
if (poll.primed) this.emit(threadId, { kind: "message", message: this.fromDto(m) });
|
||||||
|
}
|
||||||
|
poll.primed = true;
|
||||||
|
} catch {
|
||||||
|
/* transient — retry next tick */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
poll.timer = setInterval(tick, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The org directory — people you can start a DM/group with. Drives the "New message" picker. */
|
||||||
|
async directory(): Promise<Person[]> {
|
||||||
|
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
||||||
|
return dir.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ────────────────────────────────────────────────────
|
||||||
|
private async directoryMap(): Promise<Map<string, string>> {
|
||||||
|
if (!this.names) {
|
||||||
|
this.names = new Map((await this.directory()).map((p) => [p.id, p.name]));
|
||||||
|
}
|
||||||
|
return this.names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
|
||||||
|
const others = c.participants.filter((p) => p !== this.me);
|
||||||
|
const title = c.subject?.trim() || others.map((id) => names.get(id) ?? id).join(", ") || "Conversation";
|
||||||
|
return {
|
||||||
|
threadId: c.threadId,
|
||||||
|
title,
|
||||||
|
subject: c.subject,
|
||||||
|
membership: c.membership,
|
||||||
|
participants: c.participants,
|
||||||
|
unread: c.unread,
|
||||||
|
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
|
||||||
|
...(c.lastAt ? { lastAt: c.lastAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kernel Message (socket) → SDK Message. actorId = senderId (userId space), matching currentActorId. */
|
||||||
|
private fromKernel(m: KernelMessage): Message {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
actorId: m.senderId ?? null,
|
||||||
|
text: m.content ?? "",
|
||||||
|
at: m.createdAt,
|
||||||
|
parentInteractionId: m.parentInteractionId ?? null,
|
||||||
|
reactions: this.reactionsOf(m.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BFF DTO (poll fallback) → SDK Message. Note: actorId is IIOS actor-id space here. */
|
||||||
|
private fromDto(m: MessageDTO): Message {
|
||||||
|
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 ─────────────────────────────────────────────
|
||||||
|
private ingestReactions(m: KernelMessage): void {
|
||||||
|
for (const a of m.annotations ?? []) {
|
||||||
|
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
|
||||||
|
let byEmoji = this.reactions.get(messageId);
|
||||||
|
if (!byEmoji) {
|
||||||
|
byEmoji = new Map();
|
||||||
|
this.reactions.set(messageId, byEmoji);
|
||||||
|
}
|
||||||
|
if (users.length === 0) byEmoji.delete(emoji);
|
||||||
|
else byEmoji.set(emoji, new Set(users));
|
||||||
|
}
|
||||||
|
|
||||||
|
private reactionsOf(messageId: string): Reaction[] {
|
||||||
|
const byEmoji = this.reactions.get(messageId);
|
||||||
|
if (!byEmoji) return [];
|
||||||
|
const out: Reaction[] = [];
|
||||||
|
for (const [emoji, users] of byEmoji) {
|
||||||
|
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── event fan-out ──────────────────────────────────────────────
|
||||||
|
private emit(threadId: string, e: MessageEvent): void {
|
||||||
|
this.listeners.get(threadId)?.forEach((cb) => cb(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(e: MessageEvent): void {
|
||||||
|
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,95 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Inbox data layer. The inbox is a personalized work/awareness feed IIOS projects from events
|
|
||||||
// (NEEDS_REPLY, MENTION, …). The CRM lists it and transitions item state; items are never created
|
|
||||||
// here. Mock when the Shell isn't configured; live via the be-crm data door (crm.inbox.*) otherwise.
|
|
||||||
//
|
|
||||||
// Live contract (be-crm):
|
|
||||||
// query crm.inbox.list { state? } -> InboxItem[]
|
|
||||||
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
import type { MailThread } from "./mail-api";
|
|
||||||
|
|
||||||
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
|
|
||||||
export interface UiInboxItem {
|
|
||||||
id: string; kind: string; state: InboxState; title: string; summary?: string;
|
|
||||||
priority: string; threadId?: string; createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
|
|
||||||
export interface InboxData {
|
|
||||||
live: boolean; loading: boolean; error: string | null;
|
|
||||||
items: UiInboxItem[];
|
|
||||||
transition: (id: string, state: InboxState) => Promise<void>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInboxData(state?: InboxState): InboxData {
|
|
||||||
return SHELL ? useLiveInbox(state) : useMockInbox(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLiveInbox(state?: InboxState): InboxData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
|
|
||||||
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
|
|
||||||
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
|
|
||||||
const showMail = !state || state === "OPEN";
|
|
||||||
const mq = useQuery<MailThread[]>("crm.mail.list", {});
|
|
||||||
|
|
||||||
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
|
|
||||||
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
|
|
||||||
const refetchInbox = q.refetch;
|
|
||||||
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
|
|
||||||
|
|
||||||
const items = useMemo<UiInboxItem[]>(() => {
|
|
||||||
const inboxItems = q.data ?? [];
|
|
||||||
const mailItems: UiInboxItem[] = showMail
|
|
||||||
? (mq.data ?? []).map((t) => ({
|
|
||||||
id: `mail:${t.threadId}`,
|
|
||||||
kind: "MAIL",
|
|
||||||
state: "OPEN" as InboxState,
|
|
||||||
title: t.subject || "(no subject)",
|
|
||||||
...(t.lastMessage ? { summary: t.lastMessage } : {}),
|
|
||||||
priority: t.unread > 0 ? "HIGH" : "LOW",
|
|
||||||
threadId: t.threadId,
|
|
||||||
createdAt: t.lastAt ?? "",
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
// Newest first; mail and inbox items interleave by time.
|
|
||||||
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
||||||
}, [q.data, mq.data, showMail]);
|
|
||||||
|
|
||||||
const transition = useCallback(async (id: string, next: InboxState) => {
|
|
||||||
await sdk.command("crm.inbox.transition", { id, state: next });
|
|
||||||
q.refetch();
|
|
||||||
}, [sdk, q]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: true,
|
|
||||||
loading: q.loading || (showMail && mq.loading),
|
|
||||||
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
|
|
||||||
error: q.error?.message ?? null,
|
|
||||||
items,
|
|
||||||
transition,
|
|
||||||
refetch: () => { q.refetch(); mq.refetch(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_ITEMS: UiInboxItem[] = [
|
|
||||||
{ id: "in_1", kind: "MENTION", state: "OPEN", title: "Sofia mentioned you", summary: "@you — can you confirm the Henderson scope?", priority: "HIGH", threadId: "th_mock_1", createdAt: new Date().toISOString() },
|
|
||||||
{ id: "in_2", kind: "NEEDS_REPLY", state: "OPEN", title: "Reply needed — Storm response", summary: "Dan: Crew is rolling out at 7.", priority: "MEDIUM", threadId: "th_mock_2", createdAt: new Date().toISOString() },
|
|
||||||
{ id: "in_3", kind: "SUPPORT_UPDATE", state: "OPEN", title: "Ticket TK-204 updated", summary: "Customer replied on the roof-leak case.", priority: "LOW", createdAt: new Date().toISOString() },
|
|
||||||
];
|
|
||||||
|
|
||||||
function useMockInbox(state?: InboxState): InboxData {
|
|
||||||
const [items, setItems] = useState<UiInboxItem[]>(MOCK_ITEMS);
|
|
||||||
const filtered = useMemo(() => (state ? items.filter((i) => i.state === state) : items), [items, state]);
|
|
||||||
const transition = useCallback(async (id: string, next: InboxState) => {
|
|
||||||
setItems((l) => l.map((i) => (i.id === id ? { ...i, state: next } : i)));
|
|
||||||
}, []);
|
|
||||||
return { live: false, loading: false, error: null, items: filtered, transition, refetch: () => {} };
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Mail data layer. A dedicated Mail reader over the be-crm data door (crm.mail.*), distinct from
|
|
||||||
// the Messenger chat and from the work-item Inbox. Live via the AppShell SDK; a small mock keeps the
|
|
||||||
// demo working before the Shell + be-crm are connected.
|
|
||||||
//
|
|
||||||
// Live contract (be-crm):
|
|
||||||
// query crm.mail.list {} -> MailThread[]
|
|
||||||
// query crm.mail.history { threadId } -> MailMessage[]
|
|
||||||
// cmd crm.mail.reply { threadId, content } -> { interactionId, threadId }
|
|
||||||
// cmd crm.mail.internal { recipientUserId, subject?, text?, html? } -> { threadId }
|
|
||||||
// cmd crm.mail.send { target, subject?, text?, html?, mirrorToUserId? } -> { commandId }
|
|
||||||
// query crm.messenger.directory { kind, limit } -> people to compose to (reused)
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
|
|
||||||
export interface MailThread {
|
|
||||||
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
|
||||||
}
|
|
||||||
export interface MailAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null }
|
|
||||||
export interface MailMessage {
|
|
||||||
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; attachment: MailAttachment | null;
|
|
||||||
}
|
|
||||||
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
|
|
||||||
|
|
||||||
/** Shape produced by media-api's useUploadAttachment, passed into a reply/compose. */
|
|
||||||
export interface OutgoingAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
|
|
||||||
/* ============================ Thread list ============================ */
|
|
||||||
|
|
||||||
export interface MailListData {
|
|
||||||
live: boolean; loading: boolean; error: string | null; threads: MailThread[]; refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMailThreads(): MailListData {
|
|
||||||
if (SHELL) {
|
|
||||||
const q = useQuery<MailThread[]>("crm.mail.list", {});
|
|
||||||
return { live: true, loading: q.loading, error: q.error?.message ?? null, threads: q.data ?? [], refetch: q.refetch };
|
|
||||||
}
|
|
||||||
return { live: false, loading: false, error: null, threads: MOCK_THREADS, refetch: () => {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================ One thread ============================ */
|
|
||||||
|
|
||||||
export interface MailThreadData {
|
|
||||||
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string, attachment?: OutgoingAttachment) => Promise<void>; refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMailThread(threadId: string | null): MailThreadData {
|
|
||||||
if (SHELL) return useLiveThread(threadId);
|
|
||||||
return useMockThread(threadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLiveThread(threadId: string | null): MailThreadData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
|
|
||||||
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
|
|
||||||
if (!threadId) return;
|
|
||||||
await sdk.command("crm.mail.reply", { threadId, content, ...(attachment ? { attachment } : {}) });
|
|
||||||
q.refetch();
|
|
||||||
}, [sdk, threadId, q]);
|
|
||||||
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================ Compose ============================ */
|
|
||||||
|
|
||||||
export interface ComposeData {
|
|
||||||
directory: MailPerson[];
|
|
||||||
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => Promise<void>;
|
|
||||||
sendExternal: (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMailCompose(onSent: () => void): ComposeData {
|
|
||||||
if (SHELL) {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
|
||||||
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
|
|
||||||
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => {
|
|
||||||
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(attachments && attachments.length ? { attachments } : {}) });
|
|
||||||
onSent();
|
|
||||||
}, [sdk, onSent]);
|
|
||||||
const sendExternal = useCallback(async (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => {
|
|
||||||
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(opts?.mirrorToUserId ? { mirrorToUserId: opts.mirrorToUserId } : {}), ...(opts?.attachments && opts.attachments.length ? { attachments: opts.attachments } : {}) });
|
|
||||||
onSent();
|
|
||||||
}, [sdk, onSent]);
|
|
||||||
return { directory, sendInternal, sendExternal };
|
|
||||||
}
|
|
||||||
return { directory: MOCK_PEOPLE, sendInternal: async () => onSent(), sendExternal: async () => onSent() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================ Mock (demo mode) ============================ */
|
|
||||||
|
|
||||||
const now = () => new Date().toISOString();
|
|
||||||
const MOCK_PEOPLE: MailPerson[] = [
|
|
||||||
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
|
|
||||||
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
|
|
||||||
];
|
|
||||||
const MOCK_THREADS: MailThread[] = [
|
|
||||||
{ threadId: "mt_1", subject: "Welcome to the Founders Club", participants: ["you", "system"], unread: 1, lastMessage: "Thanks for joining…", lastAt: now() },
|
|
||||||
{ threadId: "mt_2", subject: "Storm response — East side", participants: ["you", "pp_sofia"], unread: 0, lastMessage: "Crew rolling out at 7", lastAt: now() },
|
|
||||||
];
|
|
||||||
function useMockThread(threadId: string | null): MailThreadData {
|
|
||||||
const [extra, setExtra] = useState<MailMessage[]>([]);
|
|
||||||
const base: MailMessage[] = threadId === "mt_1"
|
|
||||||
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club.", attachment: null }]
|
|
||||||
: threadId === "mt_2"
|
|
||||||
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7.", attachment: null }]
|
|
||||||
: [];
|
|
||||||
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
|
|
||||||
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content, attachment: attachment ? { contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, filename: attachment.filename } : null }]);
|
|
||||||
}, []);
|
|
||||||
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
|
|
||||||
}
|
|
||||||
+14
-1
@@ -14,12 +14,25 @@ export function isImage(mime?: string | null): boolean {
|
|||||||
return !!mime && mime.startsWith("image/");
|
return !!mime && mime.startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
|
||||||
|
// Fall back to the extension for the text types IIOS allows, else a generic binary.
|
||||||
|
const EXT_MIME: Record<string, string> = {
|
||||||
|
md: "text/markdown", markdown: "text/markdown",
|
||||||
|
html: "text/html", htm: "text/html",
|
||||||
|
txt: "text/plain", csv: "text/csv",
|
||||||
|
};
|
||||||
|
function mimeForFile(file: File): string {
|
||||||
|
if (file.type) return file.type;
|
||||||
|
const ext = file.name.toLowerCase().split(".").pop() ?? "";
|
||||||
|
return EXT_MIME[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
|
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
|
||||||
export function useUploadAttachment() {
|
export function useUploadAttachment() {
|
||||||
const { sdk } = useAppShell();
|
const { sdk } = useAppShell();
|
||||||
return useCallback(async (file: File): Promise<UploadedAttachment> => {
|
return useCallback(async (file: File): Promise<UploadedAttachment> => {
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
|
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
|
||||||
const mime = file.type || "application/octet-stream";
|
const mime = mimeForFile(file);
|
||||||
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
|
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
|
||||||
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Messenger data layer. Serves EITHER a local mock (when the Shell isn't configured — the demo
|
|
||||||
// keeps working) OR the live be-crm data door (crm.messenger.*), behind one interface so the UI is
|
|
||||||
// mode-agnostic. DM-vs-group + who-can-chat are enforced server-side by IIOS/OPA; this is just glue.
|
|
||||||
//
|
|
||||||
// Live contract (be-crm):
|
|
||||||
// query crm.messenger.directory { kind, query?, limit } -> DirectoryEntry[]
|
|
||||||
// query crm.messenger.conversation.list {} -> ConversationSummary[]
|
|
||||||
// cmd crm.messenger.conversation.open { participantIds[], membership?, subject? } -> { threadId, ... }
|
|
||||||
// query crm.messenger.history { threadId } -> MessengerMessage[]
|
|
||||||
// cmd crm.messenger.send { threadId, content } -> MessengerMessage
|
|
||||||
// cmd crm.messenger.participant.add { threadId, userId }
|
|
||||||
//
|
|
||||||
// v1 uses REST + polling for the live stream; v2 layers the IIOS MessageSocket (messenger-socket.tsx)
|
|
||||||
// on top for live messages, typing, read receipts, and reactions.
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import type { AnnotationEvent, AnnotationGroup } from "@insignia/iios-kernel-client";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
import { useMessengerSocket } from "./messenger-socket";
|
|
||||||
|
|
||||||
export type Membership = "dm" | "group";
|
|
||||||
export interface UiPerson { id: string; name: string; kind: "staff" | "customer" }
|
|
||||||
export interface UiConversation {
|
|
||||||
threadId: string; title: string; subject: string | null; membership: Membership | null;
|
|
||||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
|
||||||
}
|
|
||||||
export interface UiReaction { emoji: string; count: number; mine: boolean }
|
|
||||||
export interface UiAttachment { contentRef: string; mimeType: string; sizeBytes: number }
|
|
||||||
export interface UiMessage {
|
|
||||||
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
|
|
||||||
parentInteractionId?: string | null;
|
|
||||||
attachment?: UiAttachment;
|
|
||||||
reactions?: UiReaction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
|
||||||
interface ConversationDTO {
|
|
||||||
threadId: string; subject: string | null; membership: Membership | null;
|
|
||||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
|
||||||
}
|
|
||||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
const POLL_MS = 4000;
|
|
||||||
const TYPING_TTL_MS = 3500;
|
|
||||||
|
|
||||||
const shortId = (id: string) => id.replace(/^(pp_|cust_)/, "").slice(0, 6);
|
|
||||||
|
|
||||||
/** Turn the kernel's generic annotation aggregates into reaction chips. `users` may hold user or
|
|
||||||
* actor ids depending on the source, so `mine` is best-effort; a fresh annotation event corrects it. */
|
|
||||||
export function toReactions(annotations: AnnotationGroup[] | undefined, myId?: string): UiReaction[] {
|
|
||||||
if (!annotations) return [];
|
|
||||||
return annotations
|
|
||||||
.filter((a) => a.type === "reaction" && a.users.length > 0)
|
|
||||||
.map((a) => ({ emoji: a.value, count: a.users.length, mine: !!myId && a.users.includes(myId) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAnnotation(prev: UiReaction[] | undefined, e: AnnotationEvent, myId?: string): UiReaction[] {
|
|
||||||
const base = (prev ?? []).filter((r) => r.emoji !== e.value);
|
|
||||||
if (e.type !== "reaction" || e.users.length === 0) return base;
|
|
||||||
return [...base, { emoji: e.value, count: e.users.length, mine: !!myId && e.users.includes(myId) }];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Public hooks */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
export interface MessengerData {
|
|
||||||
live: boolean; loading: boolean; error: string | null;
|
|
||||||
directory: UiPerson[];
|
|
||||||
conversations: UiConversation[];
|
|
||||||
nameOf: (id: string) => string;
|
|
||||||
openConversation: (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => Promise<string>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThreadData {
|
|
||||||
loading: boolean; error: string | null;
|
|
||||||
messages: UiMessage[];
|
|
||||||
send: (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => Promise<void>;
|
|
||||||
react: (interactionId: string, emoji: string) => void;
|
|
||||||
typingUserIds: string[];
|
|
||||||
seenIds: Set<string>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UiMember { userId: string; displayName: string; role: string }
|
|
||||||
export interface GroupSettingsData {
|
|
||||||
loading: boolean; error: string | null;
|
|
||||||
members: UiMember[];
|
|
||||||
isAdmin: boolean;
|
|
||||||
rename: (subject: string) => Promise<void>;
|
|
||||||
addMember: (userId: string) => Promise<void>;
|
|
||||||
removeMember: (userId: string) => Promise<void>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMessengerData(): MessengerData {
|
|
||||||
return SHELL ? useLiveMessenger() : useMockMessenger();
|
|
||||||
}
|
|
||||||
export function useThread(threadId: string): ThreadData {
|
|
||||||
return SHELL ? useLiveThread(threadId) : useMockThread(threadId);
|
|
||||||
}
|
|
||||||
export function useGroupSettings(threadId: string): GroupSettingsData {
|
|
||||||
return SHELL ? useLiveGroupSettings(threadId) : useMockGroupSettings(threadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Live implementation (be-crm data door + IIOS socket) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
function useLiveMessenger(): MessengerData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const socket = useMessengerSocket();
|
|
||||||
const myId = user?.id;
|
|
||||||
const dirQ = useQuery<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
|
||||||
const convQ = useQuery<ConversationDTO[]>("crm.messenger.conversation.list", {});
|
|
||||||
|
|
||||||
const directory: UiPerson[] = useMemo(
|
|
||||||
() => (dirQ.data ?? []).map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })),
|
|
||||||
[dirQ.data],
|
|
||||||
);
|
|
||||||
const nameById = useMemo(() => Object.fromEntries(directory.map((p) => [p.id, p.name])), [directory]);
|
|
||||||
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
|
|
||||||
|
|
||||||
// Live sidebar previews: patch lastMessage/lastAt the instant a message arrives on any thread,
|
|
||||||
// then reconcile authoritative unread/order with a debounced refetch.
|
|
||||||
const [previews, setPreviews] = useState<Record<string, { lastMessage: string; lastAt: string }>>({});
|
|
||||||
const refetchRef = useRef(convQ.refetch);
|
|
||||||
refetchRef.current = convQ.refetch;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const off = socket.onAnyMessage((threadId, m) => {
|
|
||||||
setPreviews((p) => ({ ...p, [threadId]: { lastMessage: m.text, lastAt: m.at } }));
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => refetchRef.current(), 600);
|
|
||||||
});
|
|
||||||
return () => { off(); if (timer) clearTimeout(timer); };
|
|
||||||
}, [socket]);
|
|
||||||
|
|
||||||
const conversations: UiConversation[] = useMemo(
|
|
||||||
() => (convQ.data ?? []).map((c) => shape(c, nameOf, myId, previews[c.threadId])),
|
|
||||||
[convQ.data, nameOf, myId, previews],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refetch = useCallback(() => { dirQ.refetch(); convQ.refetch(); }, [dirQ, convQ]);
|
|
||||||
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
|
|
||||||
const res = (await sdk.command("crm.messenger.conversation.open", {
|
|
||||||
participantIds, ...(opts?.membership ? { membership: opts.membership } : {}), ...(opts?.subject ? { subject: opts.subject } : {}),
|
|
||||||
})) as { threadId: string };
|
|
||||||
convQ.refetch();
|
|
||||||
return res.threadId;
|
|
||||||
}, [sdk, convQ]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: true,
|
|
||||||
loading: dirQ.loading || convQ.loading,
|
|
||||||
error: (dirQ.error ?? convQ.error)?.message ?? null,
|
|
||||||
directory, conversations, nameOf, openConversation, refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLiveThread(threadId: string): ThreadData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const socket = useMessengerSocket();
|
|
||||||
const socketReady = socket?.ready ?? false;
|
|
||||||
const q = useQuery<MessageDTO[]>("crm.messenger.history", { threadId });
|
|
||||||
const [socketMsgs, setSocketMsgs] = useState<UiMessage[]>([]);
|
|
||||||
const [myActorId, setMyActorId] = useState<string | null>(null);
|
|
||||||
const myActorIdRef = useRef<string | null>(null);
|
|
||||||
myActorIdRef.current = myActorId;
|
|
||||||
const [typing, setTyping] = useState<Record<string, number>>({}); // userId -> expiry ts
|
|
||||||
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
|
|
||||||
const myId = socket?.myUserId;
|
|
||||||
|
|
||||||
// REST poll — the fallback whenever the live socket isn't connected.
|
|
||||||
const refetchRef = useRef(q.refetch);
|
|
||||||
refetchRef.current = q.refetch;
|
|
||||||
useEffect(() => {
|
|
||||||
if (socketReady) return;
|
|
||||||
const t = setInterval(() => refetchRef.current(), POLL_MS);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [socketReady, threadId]);
|
|
||||||
|
|
||||||
// Socket (primary): load history + subscribe to live messages, typing, receipts, reactions.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket || !socketReady) return;
|
|
||||||
let alive = true;
|
|
||||||
setSocketMsgs([]); setSeenIds(new Set()); setTyping({});
|
|
||||||
void socket.openThread(threadId).then((hist) => { if (alive) setSocketMsgs(hist); }).catch(() => {});
|
|
||||||
|
|
||||||
const offMsg = socket.subscribe(threadId, (m) =>
|
|
||||||
setSocketMsgs((l) => (l.some((x) => x.id === m.id) ? l : [...l, m])),
|
|
||||||
);
|
|
||||||
const offTyping = socket.onTyping(threadId, (userId) =>
|
|
||||||
setTyping((t) => ({ ...t, [userId]: Date.now() + TYPING_TTL_MS })),
|
|
||||||
);
|
|
||||||
// Receipts are a global stream (no threadId). Count only reads by the OTHER side; seenMine then
|
|
||||||
// narrows to my messages in this thread.
|
|
||||||
const offReceipt = socket.onReceipt((e) => {
|
|
||||||
if (e.actorId === myActorIdRef.current) return;
|
|
||||||
setSeenIds((s) => (s.has(e.interactionId) ? s : new Set(s).add(e.interactionId)));
|
|
||||||
});
|
|
||||||
const offAnn = socket.onAnnotation(threadId, (e) =>
|
|
||||||
setSocketMsgs((l) => l.map((m) => (m.id === e.interactionId ? { ...m, reactions: applyAnnotation(m.reactions, e, myId) } : m))),
|
|
||||||
);
|
|
||||||
return () => { alive = false; offMsg(); offTyping(); offReceipt(); offAnn(); };
|
|
||||||
}, [socket, socketReady, threadId, myId]);
|
|
||||||
|
|
||||||
// Learn my own actor id from a message I sent, so receipts from OTHER actors read as "seen".
|
|
||||||
useEffect(() => {
|
|
||||||
const mine = socketMsgs.find((m) => m.mine && m.actorId);
|
|
||||||
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
|
|
||||||
}, [socketMsgs, myActorId]);
|
|
||||||
|
|
||||||
// Tell the server I've read the latest message (drives the other side's "seen" tick).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket || !socketReady || socketMsgs.length === 0) return;
|
|
||||||
socket.markRead(threadId, socketMsgs[socketMsgs.length - 1].id);
|
|
||||||
}, [socket, socketReady, threadId, socketMsgs]);
|
|
||||||
|
|
||||||
// Expire stale typing entries.
|
|
||||||
const typingUserIds = useMemo(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
return Object.entries(typing).filter(([, exp]) => exp > now).map(([u]) => u);
|
|
||||||
}, [typing]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (typingUserIds.length === 0) return;
|
|
||||||
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [typingUserIds.length, typing]);
|
|
||||||
|
|
||||||
const restMsgs: UiMessage[] = useMemo(
|
|
||||||
() => (q.data ?? []).map((m) => ({
|
|
||||||
id: m.interactionId, actorId: m.actorId, senderId: null, text: m.text ?? "", at: m.occurredAt,
|
|
||||||
mine: !!myActorId && m.actorId === myActorId, reactions: [],
|
|
||||||
})),
|
|
||||||
[q.data, myActorId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const messages = socketReady ? socketMsgs : restMsgs;
|
|
||||||
|
|
||||||
// My messages the other side has read (receipts carry the other actor's id).
|
|
||||||
const seenMine = useMemo(() => {
|
|
||||||
const out = new Set<string>();
|
|
||||||
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
|
|
||||||
return out;
|
|
||||||
}, [seenIds, messages]);
|
|
||||||
|
|
||||||
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => {
|
|
||||||
if (socket && socketReady) {
|
|
||||||
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
|
|
||||||
} else {
|
|
||||||
// REST fallback carries the attachment ref too; a socket reconnect will replace with the live copy.
|
|
||||||
const m = (await sdk.command("crm.messenger.send", { threadId, content, ...(opts?.attachment ? { attachment: opts.attachment } : {}) })) as MessageDTO;
|
|
||||||
if (m.actorId) setMyActorId(m.actorId);
|
|
||||||
q.refetch();
|
|
||||||
}
|
|
||||||
}, [socket, socketReady, threadId, sdk, q]);
|
|
||||||
|
|
||||||
const react = useCallback((interactionId: string, emoji: string) => {
|
|
||||||
if (socket && socketReady) socket.react(threadId, interactionId, emoji);
|
|
||||||
}, [socket, socketReady, threadId]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading: q.loading && !socketReady, error: q.error?.message ?? null,
|
|
||||||
messages, send, react, typingUserIds, seenIds: seenMine, refetch: q.refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLiveGroupSettings(threadId: string): GroupSettingsData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const q = useQuery<UiMember[]>("crm.messenger.members", { threadId });
|
|
||||||
const members = useMemo(() => q.data ?? [], [q.data]);
|
|
||||||
const isAdmin = useMemo(() => members.some((m) => m.userId === user?.id && m.role === "ADMIN"), [members, user?.id]);
|
|
||||||
|
|
||||||
const rename = useCallback(async (subject: string) => {
|
|
||||||
await sdk.command("crm.messenger.group.rename", { threadId, subject });
|
|
||||||
q.refetch();
|
|
||||||
}, [sdk, threadId, q]);
|
|
||||||
const addMember = useCallback(async (userId: string) => {
|
|
||||||
await sdk.command("crm.messenger.participant.add", { threadId, userId });
|
|
||||||
q.refetch();
|
|
||||||
}, [sdk, threadId, q]);
|
|
||||||
const removeMember = useCallback(async (userId: string) => {
|
|
||||||
await sdk.command("crm.messenger.participant.remove", { threadId, userId });
|
|
||||||
q.refetch();
|
|
||||||
}, [sdk, threadId, q]);
|
|
||||||
|
|
||||||
return { loading: q.loading, error: q.error?.message ?? null, members, isAdmin, rename, addMember, removeMember, refetch: q.refetch };
|
|
||||||
}
|
|
||||||
|
|
||||||
function shape(
|
|
||||||
c: ConversationDTO,
|
|
||||||
nameOf: (id: string) => string,
|
|
||||||
myId: string | undefined,
|
|
||||||
overlay?: { lastMessage: string; lastAt: string },
|
|
||||||
): UiConversation {
|
|
||||||
// A DM's title is the OTHER person — never yourself, and never the raw unknown-id fallback for both.
|
|
||||||
const others = myId ? c.participants.filter((p) => p !== myId) : c.participants;
|
|
||||||
const title = c.subject?.trim()
|
|
||||||
|| (c.membership === "group"
|
|
||||||
? `Group · ${c.participants.length}`
|
|
||||||
: (others.map(nameOf).join(", ") || nameOf(c.participants[0] ?? "") || "Conversation"));
|
|
||||||
const lastMessage = overlay?.lastMessage ?? c.lastMessage;
|
|
||||||
const lastAt = overlay?.lastAt ?? c.lastAt;
|
|
||||||
return {
|
|
||||||
threadId: c.threadId, title, subject: c.subject, membership: c.membership,
|
|
||||||
participants: c.participants, unread: c.unread,
|
|
||||||
...(lastMessage ? { lastMessage } : {}), ...(lastAt ? { lastAt } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Mock implementation (no Shell configured — the demo keeps working) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
const MOCK_PEOPLE: UiPerson[] = [
|
|
||||||
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
|
|
||||||
{ id: "pp_dan", name: "Dan Whitaker", kind: "staff" },
|
|
||||||
{ id: "pp_priya", name: "Priya Nair", kind: "staff" },
|
|
||||||
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
|
|
||||||
{ id: "cust_globex", name: "Globex Homes (Client)", kind: "customer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MockThread { threadId: string; membership: Membership; subject: string | null; participants: string[]; messages: UiMessage[] }
|
|
||||||
const now = () => new Date().toISOString();
|
|
||||||
let MOCK_SEQ = 100;
|
|
||||||
|
|
||||||
// A tiny module-level store both mock hooks share, with a subscribe-on-change so the
|
|
||||||
// conversation list and the open thread stay in sync (no globalThis, no render writes).
|
|
||||||
const MOCK_STORE = new Map<string, MockThread>([
|
|
||||||
["th_mock_1", { threadId: "th_mock_1", membership: "dm", subject: null, participants: ["me", "pp_sofia"],
|
|
||||||
messages: [{ id: "m1", actorId: "pp_sofia", text: "Can you review the Henderson estimate?", at: now(), mine: false, reactions: [] }] }],
|
|
||||||
["th_mock_2", { threadId: "th_mock_2", membership: "group", subject: "Storm response — East side", participants: ["me", "pp_dan", "pp_priya"],
|
|
||||||
messages: [{ id: "m2", actorId: "pp_dan", text: "Crew is rolling out at 7.", at: now(), mine: false, reactions: [] }] }],
|
|
||||||
]);
|
|
||||||
const mockListeners = new Set<() => void>();
|
|
||||||
const notifyMock = () => mockListeners.forEach((l) => l());
|
|
||||||
function useMockSubscription(): void {
|
|
||||||
const [, setV] = useState(0);
|
|
||||||
useEffect(() => {
|
|
||||||
const l = () => setV((n) => n + 1);
|
|
||||||
mockListeners.add(l);
|
|
||||||
return () => { mockListeners.delete(l); };
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMockMessenger(): MessengerData {
|
|
||||||
useMockSubscription();
|
|
||||||
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
|
|
||||||
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
|
|
||||||
|
|
||||||
const conversations: UiConversation[] = [...MOCK_STORE.values()].map((t) => {
|
|
||||||
const last = t.messages[t.messages.length - 1];
|
|
||||||
return {
|
|
||||||
threadId: t.threadId,
|
|
||||||
title: t.subject || t.participants.filter((p) => p !== "me").map(nameOf).join(", ") || "Conversation",
|
|
||||||
subject: t.subject, membership: t.membership, participants: t.participants, unread: 0,
|
|
||||||
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
|
|
||||||
const membership = opts?.membership ?? (participantIds.length === 1 ? "dm" : "group");
|
|
||||||
const threadId = `th_mock_${MOCK_SEQ++}`;
|
|
||||||
MOCK_STORE.set(threadId, { threadId, membership, subject: opts?.subject ?? null, participants: ["me", ...participantIds], messages: [] });
|
|
||||||
notifyMock();
|
|
||||||
return threadId;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { live: false, loading: false, error: null, directory: MOCK_PEOPLE, conversations, nameOf, openConversation, refetch: () => {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMockThread(threadId: string): ThreadData {
|
|
||||||
useMockSubscription();
|
|
||||||
const thread = MOCK_STORE.get(threadId);
|
|
||||||
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string }) => {
|
|
||||||
const t = MOCK_STORE.get(threadId);
|
|
||||||
if (t) {
|
|
||||||
t.messages = [...t.messages, {
|
|
||||||
id: `m_${MOCK_SEQ++}`, actorId: "me", text: content, at: now(), mine: true, reactions: [],
|
|
||||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
|
||||||
}];
|
|
||||||
notifyMock();
|
|
||||||
}
|
|
||||||
}, [threadId]);
|
|
||||||
const react = useCallback((interactionId: string, emoji: string) => {
|
|
||||||
const t = MOCK_STORE.get(threadId);
|
|
||||||
if (!t) return;
|
|
||||||
t.messages = t.messages.map((m) => {
|
|
||||||
if (m.id !== interactionId) return m;
|
|
||||||
const has = (m.reactions ?? []).find((r) => r.emoji === emoji);
|
|
||||||
const reactions = has
|
|
||||||
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
|
|
||||||
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
|
|
||||||
return { ...m, reactions };
|
|
||||||
});
|
|
||||||
notifyMock();
|
|
||||||
}, [threadId]);
|
|
||||||
return {
|
|
||||||
loading: false, error: null, messages: thread?.messages ?? [], send, react,
|
|
||||||
typingUserIds: [], seenIds: new Set(), refetch: notifyMock,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMockGroupSettings(threadId: string): GroupSettingsData {
|
|
||||||
useMockSubscription();
|
|
||||||
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
|
|
||||||
const t = MOCK_STORE.get(threadId);
|
|
||||||
const members: UiMember[] = (t?.participants ?? []).map((id) => ({
|
|
||||||
userId: id,
|
|
||||||
displayName: id === "me" ? "You" : (nameById[id] ?? `User ${shortId(id)}`),
|
|
||||||
role: id === "me" ? "ADMIN" : "MEMBER",
|
|
||||||
}));
|
|
||||||
const rename = useCallback(async (subject: string) => {
|
|
||||||
const th = MOCK_STORE.get(threadId);
|
|
||||||
if (th) { th.subject = subject; notifyMock(); }
|
|
||||||
}, [threadId]);
|
|
||||||
const addMember = useCallback(async (userId: string) => {
|
|
||||||
const th = MOCK_STORE.get(threadId);
|
|
||||||
if (th && !th.participants.includes(userId)) { th.participants = [...th.participants, userId]; notifyMock(); }
|
|
||||||
}, [threadId]);
|
|
||||||
const removeMember = useCallback(async (userId: string) => {
|
|
||||||
const th = MOCK_STORE.get(threadId);
|
|
||||||
if (th) { th.participants = th.participants.filter((p) => p !== userId); notifyMock(); }
|
|
||||||
}, [threadId]);
|
|
||||||
return { loading: false, error: null, members, isAdmin: true, rename, addMember, removeMember, refetch: notifyMock };
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// v2 live stream: one IIOS MessageSocket for the whole Messenger panel, using the SDK
|
|
||||||
// (@insignia/iios-kernel-client) — not raw socket.io. The delegated realtime token comes from
|
|
||||||
// the be-crm data door (crm.messenger.realtime). Threads subscribe through a context; the socket
|
|
||||||
// re-opens every joined thread on reconnect (handled inside the SDK). In mock mode this is a no-op
|
|
||||||
// passthrough and the thread hook falls back to the REST poll.
|
|
||||||
//
|
|
||||||
// Beyond plain messages, the kernel exposes typing, read receipts, and reactions (generic
|
|
||||||
// annotations). This provider fans each server event out to per-thread listeners so the UI can
|
|
||||||
// render typing indicators, "seen" ticks, and emoji reactions live.
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
||||||
import { MessageSocket, type Message, type AnnotationEvent } from "@insignia/iios-kernel-client";
|
|
||||||
import { useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
import { toReactions, type UiMessage } from "./messenger-api";
|
|
||||||
|
|
||||||
interface RealtimeDTO { url: string; audience: string; token?: string }
|
|
||||||
|
|
||||||
export interface ReceiptHit { interactionId: string; actorId: string }
|
|
||||||
|
|
||||||
export interface MessengerSocket {
|
|
||||||
ready: boolean;
|
|
||||||
myUserId?: string;
|
|
||||||
openThread: (threadId: string) => Promise<UiMessage[]>;
|
|
||||||
send: (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => Promise<void>;
|
|
||||||
subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void;
|
|
||||||
/** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */
|
|
||||||
onAnyMessage: (cb: (threadId: string, m: UiMessage) => void) => () => void;
|
|
||||||
sendTyping: (threadId: string) => void;
|
|
||||||
onTyping: (threadId: string, cb: (userId: string) => void) => () => void;
|
|
||||||
markRead: (threadId: string, interactionId: string) => void;
|
|
||||||
/** The kernel's receipt event carries no threadId, so this is a global stream; the thread hook
|
|
||||||
* filters to receipts for its own (mine) messages. */
|
|
||||||
onReceipt: (cb: (e: ReceiptHit) => void) => () => void;
|
|
||||||
react: (threadId: string, interactionId: string, emoji: string) => void;
|
|
||||||
onAnnotation: (threadId: string, cb: (e: AnnotationEvent) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Ctx = createContext<MessengerSocket | null>(null);
|
|
||||||
export function useMessengerSocket(): MessengerSocket | null { return useContext(Ctx); }
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
|
|
||||||
const toUi = (m: Message, myUserId?: string): UiMessage => ({
|
|
||||||
id: m.id, actorId: m.senderActorId ?? null, senderId: m.senderId ?? null, text: m.content ?? "", at: m.createdAt,
|
|
||||||
mine: !!myUserId && m.senderId === myUserId,
|
|
||||||
...(m.parentInteractionId ? { parentInteractionId: m.parentInteractionId } : {}),
|
|
||||||
...(m.attachment ? { attachment: { contentRef: m.attachment.contentRef, mimeType: m.attachment.mimeType, sizeBytes: m.attachment.sizeBytes } } : {}),
|
|
||||||
reactions: toReactions(m.annotations, myUserId),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MessengerSocketProvider({ children }: { children: ReactNode }) {
|
|
||||||
// SHELL is a build-time constant, so the branch is stable across renders (Rules-of-Hooks safe).
|
|
||||||
if (!SHELL) return <>{children}</>;
|
|
||||||
return <LiveSocketProvider>{children}</LiveSocketProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tiny per-thread listener registry, reused for messages / typing / receipts / annotations.
|
|
||||||
function makeRegistry<T>() {
|
|
||||||
const map = new Map<string, Set<(v: T) => void>>();
|
|
||||||
const add = (key: string, cb: (v: T) => void) => {
|
|
||||||
if (!map.has(key)) map.set(key, new Set());
|
|
||||||
map.get(key)!.add(cb);
|
|
||||||
return () => { map.get(key)?.delete(cb); };
|
|
||||||
};
|
|
||||||
const emit = (key: string, v: T) => map.get(key)?.forEach((cb) => cb(v));
|
|
||||||
return { add, emit };
|
|
||||||
}
|
|
||||||
|
|
||||||
function LiveSocketProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const socketRef = useRef<MessageSocket | null>(null);
|
|
||||||
const myRef = useRef<string | undefined>(user?.id);
|
|
||||||
myRef.current = user?.id;
|
|
||||||
|
|
||||||
// One registry per event kind, keyed by threadId (plus a global message fan-out).
|
|
||||||
const msgReg = useRef(makeRegistry<UiMessage>()).current;
|
|
||||||
const anyMsg = useRef(new Set<(threadId: string, m: UiMessage) => void>()).current;
|
|
||||||
const typingReg = useRef(makeRegistry<string>()).current;
|
|
||||||
const receiptSet = useRef(new Set<(e: ReceiptHit) => void>()).current;
|
|
||||||
const annReg = useRef(makeRegistry<AnnotationEvent>()).current;
|
|
||||||
|
|
||||||
const url = rt.data?.url;
|
|
||||||
const token = rt.data?.token;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!url || !token) return;
|
|
||||||
const socket = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
|
|
||||||
socketRef.current = socket;
|
|
||||||
const offConnected = socket.onConnected(() => setReady(true));
|
|
||||||
const offMessage = socket.on("message", (m) => {
|
|
||||||
const ui = toUi(m, myRef.current);
|
|
||||||
msgReg.emit(m.threadId, ui);
|
|
||||||
anyMsg.forEach((cb) => cb(m.threadId, ui));
|
|
||||||
});
|
|
||||||
const offTyping = socket.on("typing", (e) => { if (e.userId !== myRef.current) typingReg.emit(e.threadId, e.userId); });
|
|
||||||
const offReceipt = socket.on("receipt", (e) => receiptSet.forEach((cb) => cb({ interactionId: e.interactionId, actorId: e.actorId })));
|
|
||||||
const offAnn = socket.on("annotation", (e) => annReg.emit(e.threadId, e));
|
|
||||||
socket.connect();
|
|
||||||
return () => {
|
|
||||||
offConnected(); offMessage(); offTyping(); offReceipt(); offAnn();
|
|
||||||
socket.disconnect(); socketRef.current = null; setReady(false);
|
|
||||||
};
|
|
||||||
}, [url, token, msgReg, anyMsg, typingReg, receiptSet, annReg]);
|
|
||||||
|
|
||||||
const openThread = useCallback(async (threadId: string): Promise<UiMessage[]> => {
|
|
||||||
const s = socketRef.current;
|
|
||||||
if (!s) return [];
|
|
||||||
const res = await s.openThread(threadId);
|
|
||||||
return res.history.map((m) => toUi(m, myRef.current));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const send = useCallback(async (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => {
|
|
||||||
const s = socketRef.current;
|
|
||||||
if (!s) throw new Error("Not connected");
|
|
||||||
const sendOpts = {
|
|
||||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
|
||||||
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
|
||||||
};
|
|
||||||
await s.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => msgReg.add(threadId, cb), [msgReg]);
|
|
||||||
const onAnyMessage = useCallback((cb: (threadId: string, m: UiMessage) => void) => {
|
|
||||||
anyMsg.add(cb); return () => { anyMsg.delete(cb); };
|
|
||||||
}, [anyMsg]);
|
|
||||||
const onTyping = useCallback((threadId: string, cb: (userId: string) => void) => typingReg.add(threadId, cb), [typingReg]);
|
|
||||||
const onReceipt = useCallback((cb: (e: ReceiptHit) => void) => {
|
|
||||||
receiptSet.add(cb); return () => { receiptSet.delete(cb); };
|
|
||||||
}, [receiptSet]);
|
|
||||||
const onAnnotation = useCallback((threadId: string, cb: (e: AnnotationEvent) => void) => annReg.add(threadId, cb), [annReg]);
|
|
||||||
|
|
||||||
const sendTyping = useCallback((threadId: string) => socketRef.current?.typing(threadId), []);
|
|
||||||
const markRead = useCallback((threadId: string, interactionId: string) => { void socketRef.current?.markRead(threadId, interactionId); }, []);
|
|
||||||
const react = useCallback((threadId: string, interactionId: string, emoji: string) => { void socketRef.current?.react(threadId, interactionId, emoji); }, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Ctx.Provider value={{
|
|
||||||
ready, myUserId: user?.id, openThread, send, subscribe, onAnyMessage,
|
|
||||||
sendTyping, onTyping, markRead, onReceipt, react, onAnnotation,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</Ctx.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Offline notifications data layer (Web Push). Registers the service worker, subscribes the browser
|
||||||
|
// with IIOS's VAPID key, and hands the subscription to the be-crm door so IIOS can reach this user
|
||||||
|
// while no CRM tab is open. Push needs a real backend (VAPID key + delivery), so it is only offered
|
||||||
|
// when the Shell is configured (live); demo mode reports it unsupported.
|
||||||
|
//
|
||||||
|
// Live contract (be-crm data door → IIOS /v1/notifications/*):
|
||||||
|
// query crm.messenger.push.vapidKey {} -> { key } ('' = push disabled server-side)
|
||||||
|
// cmd crm.messenger.push.subscribe { endpoint, keys, userAgent } -> { ok }
|
||||||
|
// cmd crm.messenger.push.unsubscribe { endpoint } -> { ok }
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { isShellConfigured } from "./appshell";
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
const SW_URL = "/push-sw.js";
|
||||||
|
|
||||||
|
export type PushPermission = "default" | "granted" | "denied";
|
||||||
|
|
||||||
|
export interface PushState {
|
||||||
|
/** Browser can do Web Push AND we have a live backend to deliver it. */
|
||||||
|
supported: boolean;
|
||||||
|
/** OS/browser permission for notifications. */
|
||||||
|
permission: PushPermission;
|
||||||
|
/** This browser currently has an active push subscription registered with the backend. */
|
||||||
|
subscribed: boolean;
|
||||||
|
/** A subscribe/unsubscribe round-trip is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
enable: () => Promise<void>;
|
||||||
|
disable: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VAPID keys travel as URL-safe base64; PushManager wants raw bytes. */
|
||||||
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(normalized);
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserSupportsPush(): boolean {
|
||||||
|
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize a PushSubscription into the door's { endpoint, keys } shape. */
|
||||||
|
function toSubscribeBody(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string }; userAgent: string } {
|
||||||
|
const json = sub.toJSON();
|
||||||
|
return {
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: { p256dh: json.keys?.p256dh ?? "", auth: json.keys?.auth ?? "" },
|
||||||
|
userAgent: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 400) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePushNotifications(): PushState {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const [supported] = useState<boolean>(() => SHELL && browserSupportsPush());
|
||||||
|
const [permission, setPermission] = useState<PushPermission>("default");
|
||||||
|
const [subscribed, setSubscribed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reflect the current OS permission + whether a subscription already exists (e.g. across reloads).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return;
|
||||||
|
setPermission(Notification.permission as PushPermission);
|
||||||
|
let cancelled = false;
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((reg) => reg.pushManager.getSubscription())
|
||||||
|
.then((sub) => {
|
||||||
|
if (!cancelled) setSubscribed(Boolean(sub));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [supported]);
|
||||||
|
|
||||||
|
const enable = useCallback(async () => {
|
||||||
|
if (!supported) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
setPermission(perm as PushPermission);
|
||||||
|
if (perm !== "granted") throw new Error("Notifications permission was not granted.");
|
||||||
|
|
||||||
|
const reg = await navigator.serviceWorker.register(SW_URL);
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
const { key } = await sdk.query<{ key: string }>("crm.messenger.push.vapidKey", {});
|
||||||
|
if (!key) throw new Error("Push is not enabled on the server (no VAPID key).");
|
||||||
|
|
||||||
|
const existing = await reg.pushManager.getSubscription();
|
||||||
|
const sub =
|
||||||
|
existing ??
|
||||||
|
(await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
// Cast: this lib's BufferSource type pins ArrayBuffer, but a plain Uint8Array is valid here.
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(key) as BufferSource,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await sdk.command("crm.messenger.push.subscribe", toSubscribeBody(sub));
|
||||||
|
setSubscribed(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Could not enable notifications.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [supported, sdk]);
|
||||||
|
|
||||||
|
const disable = useCallback(async () => {
|
||||||
|
if (!supported) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
if (sub) {
|
||||||
|
// Tell the backend first (still has the endpoint), then drop the local subscription.
|
||||||
|
await sdk.command("crm.messenger.push.unsubscribe", { endpoint: sub.endpoint }).catch(() => {});
|
||||||
|
await sub.unsubscribe();
|
||||||
|
}
|
||||||
|
setSubscribed(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Could not disable notifications.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [supported, sdk]);
|
||||||
|
|
||||||
|
return { supported, permission, subscribed, busy, error, enable, disable };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// One shared IIOS message socket for the whole dashboard, so the messenger tab AND the app-wide
|
||||||
|
// notification center use a single connection (not one each). Opened at the dashboard level and
|
||||||
|
// kept alive across tab switches; the messenger tab reuses it via useRealtime().
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useQuery } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { MessageSocket } from "@insignia/iios-kernel-client";
|
||||||
|
import { isShellConfigured } from "./appshell";
|
||||||
|
|
||||||
|
const RealtimeContext = createContext<MessageSocket | null>(null);
|
||||||
|
interface RealtimeDTO { url: string; audience: string; token?: string }
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
function LiveRealtimeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
|
||||||
|
const [socket, setSocket] = useState<MessageSocket | null>(null);
|
||||||
|
const url = rt.data?.url;
|
||||||
|
const token = rt.data?.token;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || !token) return;
|
||||||
|
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
|
||||||
|
s.connect();
|
||||||
|
setSocket(s);
|
||||||
|
return () => {
|
||||||
|
s.disconnect();
|
||||||
|
setSocket(null);
|
||||||
|
};
|
||||||
|
}, [url, token]);
|
||||||
|
return <RealtimeContext.Provider value={socket}>{children}</RealtimeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||||
|
// SHELL is a build-time constant, so the same branch runs every render (Rules-of-Hooks safe).
|
||||||
|
if (!SHELL) return <>{children}</>;
|
||||||
|
return <LiveRealtimeProvider>{children}</LiveRealtimeProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared socket, or null (demo mode / not yet connected). */
|
||||||
|
export function useRealtime(): MessageSocket | null {
|
||||||
|
return useContext(RealtimeContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Global conversation search. Live path calls the be-crm data door (crm.search → IIOS Meilisearch,
|
||||||
|
// permission-scoped there); demo path filters a small in-memory set. Search is imperative (the query
|
||||||
|
// changes on every keystroke), so it uses sdk.query directly rather than the cached useQuery hook.
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { isShellConfigured } from "./appshell";
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
interactionId: string;
|
||||||
|
threadId: string;
|
||||||
|
surface: "messenger" | "inbox";
|
||||||
|
title: string;
|
||||||
|
/** Snippet with <em>…</em> around the matched terms. */
|
||||||
|
snippet: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchFn = (query: string) => Promise<SearchResult[]>;
|
||||||
|
|
||||||
|
const MOCK: SearchResult[] = [
|
||||||
|
{ interactionId: "s1", threadId: "th_mock_1", surface: "messenger", title: "Sofia Ramirez", snippet: "Can you confirm the <em>Henderson</em> scope?", at: Date.now() },
|
||||||
|
{ interactionId: "s2", threadId: "th_mock_2", surface: "messenger", title: "Storm response — East side", snippet: "Crew is <em>rolling</em> out at 7", at: Date.now() },
|
||||||
|
{ interactionId: "s3", threadId: "mt_invoice", surface: "inbox", title: "Invoice #1042 — Acme Roofing", snippet: "Attached is <em>invoice</em> #1042 for the East-side job", at: Date.now() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
function useLiveSearch(): SearchFn {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
return useCallback(async (query: string) => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
return sdk.query<SearchResult[]>("crm.search", { query: q, limit: 20 });
|
||||||
|
}, [sdk]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMockSearch(): SearchFn {
|
||||||
|
return useCallback(async (query: string) => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return MOCK.filter((m) => (m.title + " " + m.snippet).toLowerCase().includes(q));
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSearch(): SearchFn {
|
||||||
|
return SHELL ? useLiveSearch() : useMockSearch();
|
||||||
|
}
|
||||||
@@ -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"}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// SMS settings data layer. Serves EITHER the local mock (Shell not configured — the demo keeps
|
||||||
|
// working) OR the live be-crm data door (crm.settings.sms.*), behind one interface.
|
||||||
|
//
|
||||||
|
// Live contract (be-crm → IIOS BYO credential store):
|
||||||
|
// query crm.settings.sms.status {} -> { configured, enabled?, hints? }
|
||||||
|
// cmd crm.settings.sms.configure { accountSid, authToken, fromNumber } -> masked status
|
||||||
|
// The auth token is write-only: it is sealed in IIOS and NEVER returned — status carries only
|
||||||
|
// non-secret hints (from-number + SID last-4).
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { isShellConfigured } from "./appshell";
|
||||||
|
|
||||||
|
export interface SmsCredentials { accountSid: string; authToken: string; fromNumber: string }
|
||||||
|
|
||||||
|
export interface SmsStatus {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
fromNumber?: string;
|
||||||
|
sidLast4?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsSettingsData {
|
||||||
|
live: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
status: SmsStatus;
|
||||||
|
configure: (input: SmsCredentials) => Promise<void>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { fromNumber?: string; sidLast4?: string } }
|
||||||
|
|
||||||
|
function toStatus(dto?: StatusDTO | null): SmsStatus {
|
||||||
|
return {
|
||||||
|
configured: !!dto?.configured,
|
||||||
|
enabled: dto?.enabled ?? false,
|
||||||
|
fromNumber: dto?.hints?.fromNumber,
|
||||||
|
sidLast4: dto?.hints?.sidLast4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mock (demo mode) — stores only the non-secret hints, mirroring the masked live status ---- */
|
||||||
|
function useMockSms(): SmsSettingsData {
|
||||||
|
const [status, setStatus] = useState<SmsStatus>({ configured: false, enabled: false });
|
||||||
|
const configure = useCallback(async ({ accountSid, fromNumber }: SmsCredentials) => {
|
||||||
|
setStatus({ configured: true, enabled: true, fromNumber, sidLast4: accountSid.slice(-4) });
|
||||||
|
}, []);
|
||||||
|
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Live (be-crm data door) ---- */
|
||||||
|
function useLiveSms(): SmsSettingsData {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const q = useQuery<StatusDTO>("crm.settings.sms.status", {});
|
||||||
|
const configure = useCallback(async (input: SmsCredentials) => {
|
||||||
|
await sdk.command("crm.settings.sms.configure", { ...input });
|
||||||
|
q.refetch();
|
||||||
|
}, [sdk, q]);
|
||||||
|
return {
|
||||||
|
live: true,
|
||||||
|
loading: q.loading,
|
||||||
|
error: q.error ? String(q.error) : null,
|
||||||
|
status: toStatus(q.data),
|
||||||
|
configure,
|
||||||
|
refetch: q.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
export function useSmsSettings(): SmsSettingsData {
|
||||||
|
// SHELL is constant for the bundle's life (NEXT_PUBLIC_* is build-time), so the same hook path
|
||||||
|
// runs every render — Rules-of-Hooks safe.
|
||||||
|
return SHELL ? useLiveSms() : useMockSms();
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// SMTP settings data layer — a tenant's own outbound email server (BYO). Serves the local mock
|
||||||
|
// (Shell not configured) or the live be-crm data door (crm.settings.smtp.*).
|
||||||
|
//
|
||||||
|
// Live contract (be-crm → IIOS BYO credential store):
|
||||||
|
// query crm.settings.smtp.status {} -> { configured, enabled?, hints? }
|
||||||
|
// cmd crm.settings.smtp.configure { host, port, secure, user, pass, fromEmail, fromName? } -> masked status
|
||||||
|
// The password is write-only: sealed in IIOS, never returned — status carries only non-secret hints.
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { isShellConfigured } from "./appshell";
|
||||||
|
|
||||||
|
export interface SmtpCredentials {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
fromEmail: string;
|
||||||
|
fromName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpStatus {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
user?: string;
|
||||||
|
fromEmail?: string;
|
||||||
|
fromName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpSettingsData {
|
||||||
|
live: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
status: SmtpStatus;
|
||||||
|
configure: (input: SmtpCredentials) => Promise<void>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { host?: string; port?: number; user?: string; fromEmail?: string; fromName?: string } }
|
||||||
|
|
||||||
|
function toStatus(dto?: StatusDTO | null): SmtpStatus {
|
||||||
|
return {
|
||||||
|
configured: !!dto?.configured,
|
||||||
|
enabled: dto?.enabled ?? false,
|
||||||
|
host: dto?.hints?.host,
|
||||||
|
port: dto?.hints?.port,
|
||||||
|
user: dto?.hints?.user,
|
||||||
|
fromEmail: dto?.hints?.fromEmail,
|
||||||
|
fromName: dto?.hints?.fromName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mock (demo mode) — stores only the non-secret hints ---- */
|
||||||
|
function useMockSmtp(): SmtpSettingsData {
|
||||||
|
const [status, setStatus] = useState<SmtpStatus>({ configured: false, enabled: false });
|
||||||
|
const configure = useCallback(async ({ host, port, user, fromEmail, fromName }: SmtpCredentials) => {
|
||||||
|
setStatus({ configured: true, enabled: true, host, port, user, fromEmail, ...(fromName ? { fromName } : {}) });
|
||||||
|
}, []);
|
||||||
|
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Live (be-crm data door) ---- */
|
||||||
|
function useLiveSmtp(): SmtpSettingsData {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const q = useQuery<StatusDTO>("crm.settings.smtp.status", {});
|
||||||
|
const configure = useCallback(async (input: SmtpCredentials) => {
|
||||||
|
await sdk.command("crm.settings.smtp.configure", { ...input });
|
||||||
|
q.refetch();
|
||||||
|
}, [sdk, q]);
|
||||||
|
return {
|
||||||
|
live: true,
|
||||||
|
loading: q.loading,
|
||||||
|
error: q.error ? String(q.error) : null,
|
||||||
|
status: toStatus(q.data),
|
||||||
|
configure,
|
||||||
|
refetch: q.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
export function useSmtpSettings(): SmtpSettingsData {
|
||||||
|
return SHELL ? useLiveSmtp() : useMockSmtp();
|
||||||
|
}
|
||||||
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.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../nanoid/bin/nanoid.js
|
||||||
+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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user