Merge origin/goutamnextflow into feat/leads

Resolve conflicts in dashboard.tsx and dashboard.css:
- Keep goutamnextflow's SDK inbox/messenger, settings, notifications,
  realtime provider and smart gallery (the old messenger/inbox files
  were deleted on that branch)
- Graft the feat/leads additions (Leads, Verify views + their CSS) on top

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mayur Shinde
2026-07-23 18:50:27 +05:30
130 changed files with 23582 additions and 1778 deletions
+80
View File
@@ -14,3 +14,83 @@ BFF_ORIGIN=http://localhost:4000
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
# locally: export NODE_AUTH_TOKEN=<token> before npm install
# 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.
+230
View File
@@ -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
View File
@@ -1,8 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The SDKs ship ESM/TS source (their `exports` point at src/), so Next must
// 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
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
+1073 -22
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -6,20 +6,31 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"sync:gallery-sdk": "node scripts/sync-gallery-sdk.mjs"
},
"dependencies": {
"@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-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",
"leaflet": "^1.9.4",
"lucide-react": "^1.21.0",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"tesseract.js": "5.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/leaflet": "^1.9.21",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+50
View File
@@ -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);
})(),
);
});
+73
View File
@@ -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.");
+61
View File
@@ -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 };
}
+71
View File
@@ -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 });
}
}
+48
View File
@@ -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 });
}
}
+419
View File
@@ -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[] } }>;
}
+48
View File
@@ -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 });
}
}
+134
View File
@@ -134,6 +134,21 @@
.dash-content { padding: 22px 28px 40px; width: 100%; }
.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 { display: grid; gap: 16px; }
.row { display: flex; align-items: center; }
@@ -1384,3 +1399,122 @@
.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); }
+40 -5
View File
@@ -15,8 +15,12 @@ import { Support } from "./support";
import { Rules } from "./rules";
import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { Messenger } from "./messenger";
import { Inbox } from "./inbox";
import { MessengerSdk } from "./messenger-sdk";
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 { Verify } from "./verify";
import "../../app/dashboard/dashboard.css";
@@ -24,12 +28,38 @@ import "../../app/dashboard/dashboard.css";
export function Dashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
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(() => {
// Sync the persisted theme from localStorage (an external system) on mount.
// 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 {}
}, []);
// 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() {
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 (
<div className="dash-root" data-theme={theme}>
<RealtimeProvider>
<Sidebar active={active} onSelect={setActive} />
<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">
<ToastProvider>
<NotificationCenter active={active} onNavigate={navigateToConversation} />
{active === "profile" ? <Profile />
: active === "support" ? <Support />
: active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <Messenger />
: active === "inbox" ? <Inbox />
: active === "messenger" ? <MessengerSdk focusThreadId={deepLink?.surface === "messenger" ? deepLink.threadId : null} />
: active === "inbox" ? <InboxSdk focusThreadId={deepLink?.surface === "inbox" ? deepLink.threadId : null} />
: active === "settings" ? <Settings />
: active === "gallery" ? <SmartGallery theme={theme} />
: active === "leads" ? <Leads />
: active === "verify" ? <Verify />
: active === "team" ? <TeamManagement />
@@ -58,6 +92,7 @@ export function Dashboard() {
</ToastProvider>
</div>
</div>
</RealtimeProvider>
</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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return esc.replace(/&lt;em&gt;/g, "<em>").replace(/&lt;\/em&gt;/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>
);
}
+48
View File
@@ -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&apos;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>
);
}
-152
View File
@@ -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&apos;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>
)}
</>
);
}
-231
View File
@@ -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 labelinput 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&apos;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>
);
}
-550
View File
@@ -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;
}
+222
View File
@@ -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 165535.";
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>
);
}
+10 -1
View File
@@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{ 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: "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
// 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.
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> = {
team: "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}
/>
);
}
+122
View File
@@ -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&apos;t have access to the gallery</h3>
<p>Ask a workspace admin to grant you the &ldquo;View Smart Gallery&rdquo; 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>
);
}
}
+5 -7
View File
@@ -4,14 +4,15 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-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";
function initialsOf(name: string): string {
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
// Envelope; otherwise fall back to the static demo user.
const router = useRouter();
@@ -35,14 +36,11 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
<p>{subtitle}</p>
</div>
<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}>
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
</button>
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
<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>
<NotificationBell />
<div className="top-user-wrap" style={{ position: "relative" }}>
<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>
+12 -4
View File
@@ -24,7 +24,8 @@ import {
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
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";
/* ---------------------------------------------------------- */
@@ -50,6 +51,10 @@ const ICONS: Record<string, LucideIcon> = {
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
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 }) {
@@ -260,7 +265,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
/* 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 };
const ToastContext = createContext<ToastCtx | null>(null);
@@ -283,9 +288,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children}
<div className="ds-toasts">
{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} />
<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>
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
</div>
+121
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 })) };
}
+392
View File
@@ -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));
}
}
+734
View File
@@ -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,
};
}
+418
View File
@@ -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,
};
-95
View File
@@ -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: () => {} };
}
-121
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/* ============================ 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
View File
@@ -14,12 +14,25 @@ export function isImage(mime?: string | null): boolean {
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. */
export function useUploadAttachment() {
const { sdk } = useAppShell();
return useCallback(async (file: File): Promise<UploadedAttachment> => {
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 res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
-435
View File
@@ -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 };
}
-149
View File
@@ -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>
);
}
+138
View File
@@ -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 };
}
+43
View File
@@ -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);
}
+50
View File
@@ -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();
}
+86
View File
@@ -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;
}
+86
View File
@@ -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;
}
+134
View File
@@ -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));
}
+301
View File
@@ -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)) };
}
+68
View File
@@ -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[];
}
+139
View File
@@ -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"}`;
}
+78
View File
@@ -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();
}
+89
View File
@@ -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();
}
+38
View File
@@ -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.
+288
View File
@@ -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 10001300 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
View File
@@ -0,0 +1 @@
../nanoid/bin/nanoid.js
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,2 @@
export let urlAlphabet =
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
+41
View File
@@ -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."
}
}
+122
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+187
View File
@@ -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;
});
}
+61
View File
@@ -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
View File
@@ -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())}`;
}
+171
View File
@@ -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>
);
}
+839
View File
@@ -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 18 → 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>
);
}
+199
View File
@@ -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>
);
}
+45
View File
@@ -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 };
+62
View File
@@ -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>
);
}
+403
View File
@@ -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>
);
}
+194
View File
@@ -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);
+127
View File
@@ -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
View File
@@ -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>
);
}
+385
View File
@@ -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>
);
}
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useRef, useState } from 'react';
import { Icon } from '../icons';
import { isAcceptedMediaFile } from '../lib/media';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { AlbumId } from '../types';
import { closeModal, openModal } from './Modal';
/** Use the SAME allow-list the import pipeline enforces (mediaFromFile), so the
* "N files ready" count matches what will actually be accepted no silent skips. */
const acceptFile = (f: File) => isAcceptedMediaFile(f);
function UploadModal({ defaultAlbumId }: { defaultAlbumId?: AlbumId }) {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums.filter((a) => a.kind === 'user' || a.kind === 'folder'));
const [files, setFiles] = useState<File[]>([]);
const [rejected, setRejected] = useState(0);
const [album, setAlbum] = useState<string>(defaultAlbumId ?? '');
const [busy, setBusy] = useState(false);
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const add = (list: FileList | File[] | null) => {
if (!list) return;
const all = Array.from(list);
const ok = all.filter(acceptFile);
setRejected((r) => r + (all.length - ok.length));
if (ok.length) setFiles((prev) => [...prev, ...ok]);
};
// Open the native OS file picker. A real, explicit control triggering input.click()
// is the most reliable cross-browser way to open the file dialog.
const browse = () => inputRef.current?.click();
const upload = async () => {
if (!files.length) return;
setBusy(true);
try {
await api.getState().importFiles(files, album || undefined);
closeModal();
} finally {
setBusy(false);
}
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Add photos and videos" style={{ minWidth: 400 }}>
<div className="apg-modal__title">Add Photos &amp; Videos</div>
<button
type="button"
className={['apg-upload__drop', drag ? 'apg-upload__drop--over' : ''].join(' ')}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
add(e.dataTransfer.files);
}}
onClick={browse}
>
<Icon name="download" size={28} />
<div style={{ fontWeight: 600 }}>Drag &amp; drop here, or click to browse</div>
<div style={{ fontSize: 12, color: 'var(--apg-text-secondary)' }}>
Images &amp; videos only · multiple allowed
</div>
{files.length ? (
<div style={{ fontSize: 13, marginTop: 6, color: 'var(--apg-accent)' }}>
{files.length} file{files.length === 1 ? '' : 's'} ready
</div>
) : null}
{rejected ? (
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--apg-danger)' }}>
{rejected} unsupported file{rejected === 1 ? '' : 's'} skipped
</div>
) : null}
</button>
{/* Explicit, always-visible browse control (the dropzone click can be flaky on
some setups; a real button reliably opens the OS file dialog). */}
<button
type="button"
className="apg-btn"
style={{ width: '100%', marginTop: 8, cursor: 'pointer' }}
onClick={browse}
>
Choose files
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
// visually hidden (not the `hidden` attribute — some browsers refuse a
// programmatic .click() on a display:none file input).
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
onChange={(e) => {
add(e.target.files);
// Reset so choosing the SAME file again still fires onChange.
e.target.value = '';
}}
/>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 12, fontSize: 12 }}>
<span style={{ color: 'var(--apg-text-secondary)' }}>Add to album</span>
<select className="apg-modal__input" value={album} onChange={(e) => setAlbum(e.target.value)}>
<option value="">Library only</option>
{albums.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal} disabled={busy}>
Cancel
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => void upload()}
disabled={busy || files.length === 0}
>
{busy ? 'Uploading…' : `Upload${files.length ? ` ${files.length}` : ''}`}
</button>
</div>
</div>
);
}
/** Open the upload modal, defaulting the album to the currently-open one. */
export function openUploadModal(defaultAlbumId?: AlbumId) {
openModal(<UploadModal defaultAlbumId={defaultAlbumId} />);
}
+248
View File
@@ -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>
);
}
+48
View File
@@ -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 />;
}
}
+12
View File
@@ -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 doublearrow
with a centered label (e.g. 12 cm). Pick a tool, then drag on the photo.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{ANN_TOOLS.map((t) => (
<button
key={t.tool}
type="button"
className={['apg-editor__tab', annTool === t.tool ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setAnnTool(t.tool)}
>
<Icon name={t.icon} size={15} /> {t.label}
</button>
))}
</div>
{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 &amp; 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
View File
@@ -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