forked from Goutam/lynkeduppro-crm
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:
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared entry gate for every /api/gallery/ai/* route: authenticate, then
|
||||
* throttle. Lives in a `_`-prefixed file so the App Router never treats it as a
|
||||
* route (only `route.ts` defines an endpoint).
|
||||
*
|
||||
* Order matters: we authenticate FIRST so the rate limit can be keyed by
|
||||
* principal rather than by a spoofable `x-forwarded-for` hop wherever possible.
|
||||
* The session check is one cheap BFF round trip; the work it guards is a GPU
|
||||
* call, so paying it before throttling is the right trade.
|
||||
*
|
||||
* SERVER-ONLY.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { limit } from "@/lib/server/rate-limit";
|
||||
import { rateLimitKey, requireGallerySession } from "@/lib/server/session";
|
||||
|
||||
/** Per-minute budgets, per the Smart Gallery route contract. */
|
||||
export const RATE_LIMITS = {
|
||||
classify: 30,
|
||||
edit: 12,
|
||||
tilt: 30,
|
||||
transcribe: 20,
|
||||
denoise: 20,
|
||||
} as const;
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
export type GuardResult =
|
||||
| { ok: true; principalId?: string }
|
||||
/** Ready-to-return error response — the route should return it unchanged. */
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
/**
|
||||
* @param route Which budget to apply (also namespaces the limiter key so a
|
||||
* caller's `edit` spend does not consume their `classify` budget).
|
||||
*/
|
||||
export async function guard(req: Request, route: keyof typeof RATE_LIMITS): Promise<GuardResult> {
|
||||
const session = await requireGallerySession(req);
|
||||
if (!session.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: session.error }, { status: session.status }),
|
||||
};
|
||||
}
|
||||
|
||||
const key = `${route}:${rateLimitKey(req, session.principalId)}`;
|
||||
const { ok, retryAfter } = limit(key, RATE_LIMITS[route], WINDOW_MS);
|
||||
if (!ok) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: "Too many requests — slow down." },
|
||||
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, principalId: session.principalId };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpDetect } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Object-detection proxy for the RunPod YOLO construction-material classifier (#1).
|
||||
* The key + endpoint URL stay server-side. The client calls this only when
|
||||
* NEXT_PUBLIC_APG_RUNPOD_DETECT is on; otherwise detection runs fully in-browser
|
||||
* (COCO-SSD) with no server round-trip. Returns the SDK's DetectedObject[] shape
|
||||
* (box as 0..1 fractions) so it drops straight into the Objects browser / smart
|
||||
* albums / search.
|
||||
*
|
||||
* POST { imageBase64, width, height } -> { objects: [{ label, confidence, box }] }
|
||||
* Auth: session-gated (see lib/server/session.ts). Rate limit: 30/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — under serverless body limits
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "classify");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
if (!process.env.RUNPOD_API_KEY || !process.env.RUNPOD_YOLO_URL) {
|
||||
return NextResponse.json(
|
||||
{ error: "RunPod detection is not configured (set RUNPOD_API_KEY + RUNPOD_YOLO_URL)." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { imageBase64, width, height } = (body ?? {}) as {
|
||||
imageBase64?: unknown;
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof imageBase64 !== "string" ||
|
||||
imageBase64.length === 0 ||
|
||||
imageBase64.length > MAX_BASE64
|
||||
) {
|
||||
return NextResponse.json({ error: "Invalid or oversized image." }, { status: 400 });
|
||||
}
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
|
||||
try {
|
||||
const objects = await rpDetect(
|
||||
imageBase64,
|
||||
Number.isFinite(w) && w > 0 ? w : 1,
|
||||
Number.isFinite(h) && h > 0 ? h : 1,
|
||||
);
|
||||
return NextResponse.json({ objects });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Detection failed.";
|
||||
const status = err instanceof RunpodError ? err.status : 502;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpDenoiseAudio } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // cold-start denoise worker can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Audio noise-removal proxy. Accepts base64 WAV (48 kHz mono PCM16, produced
|
||||
* in-browser) and returns a cleaned base64 WAV. Calls the RunPod audio-denoise
|
||||
* endpoint (RUNPOD_AUDIO_DENOISE_URL) — key stays server-side. Used before
|
||||
* transcription on noisy sites.
|
||||
*
|
||||
* POST { audio } -> { audio }
|
||||
* Auth: session-gated. Rate limit: 20/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 12_000_000; // ~9 MB decoded WAV
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "denoise");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
let body: { audio?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const audio = typeof body.audio === "string" ? body.audio : "";
|
||||
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
|
||||
if (audio.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { audioB64 } = await rpDenoiseAudio(audio);
|
||||
return NextResponse.json({ audio: audioB64 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Denoise failed.";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import {
|
||||
rpImg2Img,
|
||||
rpInpaint,
|
||||
rpRemoveBackground,
|
||||
rpUpscale,
|
||||
} from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // SD / cold-start models can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Generative image-edit proxy. The BACKEND is pluggable — pick one with env
|
||||
* `AI_EDIT_PROVIDER` (default `auto`):
|
||||
*
|
||||
* - `runpod` → RunPod serverless GPU endpoints (one per model). Maps each
|
||||
* op → endpoint: restore/upscale → Real-ESRGAN (#7), colorize
|
||||
* → img2img (#10), replace-sky / magic-eraser / generative-fill
|
||||
* → SD 3.5 masked inpaint (#9), prompt → SD 3.5 img2img (#10).
|
||||
* Env: RUNPOD_API_KEY + per-model RUNPOD_*_URL. Key stays
|
||||
* server-side.
|
||||
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
|
||||
* SD.Next img2img API). Env: LOCAL_SD_URL.
|
||||
* - `huggingface` → Hugging Face Inference API. Env: HF_API_TOKEN, HF_IMAGE_MODEL.
|
||||
* - `gemini` → Google Gemini image model (needs a billed key for image output).
|
||||
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
|
||||
* - `auto` → first configured of: runpod → local → huggingface → gemini.
|
||||
*
|
||||
* NOTE: `remove-background` runs in-browser by default (@imgly, no key), so it
|
||||
* usually never reaches here. Object detection uses its own route (./classify).
|
||||
*
|
||||
* POST { imageBase64, mimeType?, op, maskBase64?, params? } -> { imageBase64, mimeType }
|
||||
* Auth: session-gated. Rate limit: 12/min (the most expensive route).
|
||||
*/
|
||||
|
||||
const OP_PROMPTS: Record<string, string> = {
|
||||
restore:
|
||||
"Restore and enhance this photograph: improve sharpness and clarity, correct exposure and white balance, reduce noise and compression artifacts, recover detail. Keep it natural and photorealistic.",
|
||||
colorize: "Colorize this image with natural, realistic, well-balanced colors.",
|
||||
"replace-sky":
|
||||
"Replace the sky with a dramatic, beautiful golden-hour sky with soft clouds. Keep the foreground subject unchanged and the result photorealistic.",
|
||||
};
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — stays under serverless body limits
|
||||
|
||||
type Provider = "runpod" | "local" | "huggingface" | "gemini" | "none";
|
||||
|
||||
function resolveProvider(): Provider {
|
||||
const explicit = (process.env.AI_EDIT_PROVIDER || "auto").toLowerCase();
|
||||
if (
|
||||
explicit === "runpod" ||
|
||||
explicit === "local" ||
|
||||
explicit === "huggingface" ||
|
||||
explicit === "gemini"
|
||||
)
|
||||
return explicit;
|
||||
if (explicit === "none") return "none";
|
||||
// auto: prefer RunPod GPU endpoints, then a private local server, then HF, then Gemini.
|
||||
// Detect RunPod when the key + ANY image endpoint URL is set (an upscale/colorize-only
|
||||
// deployment is valid — not just the SD ones).
|
||||
if (
|
||||
process.env.RUNPOD_API_KEY &&
|
||||
(process.env.RUNPOD_SD_IMG2IMG_URL ||
|
||||
process.env.RUNPOD_SD_INPAINT_URL ||
|
||||
process.env.RUNPOD_UPSCALE_URL ||
|
||||
process.env.RUNPOD_COLORIZE_URL ||
|
||||
process.env.RUNPOD_BG_REMOVE_URL)
|
||||
)
|
||||
return "runpod";
|
||||
if (process.env.LOCAL_SD_URL) return "local";
|
||||
if (process.env.HF_API_TOKEN) return "huggingface";
|
||||
if (process.env.GEMINI_API_KEY) return "gemini";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Ops that only the RunPod (mask/fixed-function) backend can serve. */
|
||||
const RUNPOD_ONLY_OPS = new Set(["upscale", "magic-eraser", "generative-fill"]);
|
||||
|
||||
interface EditResult {
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "edit");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
const provider = resolveProvider();
|
||||
if (provider === "none") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"AI image editing is not configured. Set AI_EDIT_PROVIDER=runpod + RUNPOD_API_KEY + the per-model RUNPOD_*_URL vars (RunPod GPU), or LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { imageBase64, mimeType, op, maskBase64, params } = (body ?? {}) as {
|
||||
imageBase64?: unknown;
|
||||
mimeType?: unknown;
|
||||
op?: { type?: string; prompt?: string; factor?: number };
|
||||
maskBase64?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid image." }, { status: 400 });
|
||||
}
|
||||
const hasMask = typeof maskBase64 === "string" && maskBase64.length > 0;
|
||||
// Image + mask share one request body — budget them together against the cap.
|
||||
if (imageBase64.length + (hasMask ? (maskBase64 as string).length : 0) > MAX_BASE64) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image (plus mask) is too large — try a smaller image." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const safeMime =
|
||||
typeof mimeType === "string" && /^image\/(jpeg|png|webp)$/.test(mimeType)
|
||||
? mimeType
|
||||
: "image/jpeg";
|
||||
|
||||
const opType = op?.type ?? "";
|
||||
if (provider !== "runpod" && RUNPOD_ONLY_OPS.has(opType)) {
|
||||
return NextResponse.json(
|
||||
{ error: "This edit needs the RunPod backend (set AI_EDIT_PROVIDER=runpod)." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
|
||||
let instruction = "";
|
||||
if (opType === "prompt" || opType === "generative-fill") {
|
||||
const p = typeof op?.prompt === "string" ? op.prompt.trim() : "";
|
||||
if (!p) return NextResponse.json({ error: "Empty prompt." }, { status: 400 });
|
||||
instruction = p.slice(0, 500);
|
||||
} else if (opType === "replace-sky") {
|
||||
instruction =
|
||||
typeof op?.prompt === "string" && op.prompt.trim()
|
||||
? `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`
|
||||
: OP_PROMPTS["replace-sky"]!;
|
||||
} else if (opType === "magic-eraser") {
|
||||
instruction =
|
||||
"Fill the selected region with a clean, seamless, plausible background. Photorealistic.";
|
||||
} else if (OP_PROMPTS[opType]) {
|
||||
instruction = OP_PROMPTS[opType]!;
|
||||
} else if (opType !== "upscale" && opType !== "remove-background") {
|
||||
return NextResponse.json({ error: "Unsupported operation." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
let result: EditResult;
|
||||
if (provider === "runpod")
|
||||
result = await editRunPod(
|
||||
op ?? {},
|
||||
imageBase64,
|
||||
instruction,
|
||||
hasMask ? (maskBase64 as string) : undefined,
|
||||
params,
|
||||
);
|
||||
else if (provider === "local") result = await editLocal(instruction, imageBase64);
|
||||
else if (provider === "huggingface") result = await editHuggingFace(instruction, imageBase64);
|
||||
else result = await editGemini(instruction, imageBase64, safeMime);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "AI request failed.";
|
||||
const status = err instanceof AiError || err instanceof RunpodError ? err.status : 502;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: RunPod serverless GPU endpoints (one model per endpoint).
|
||||
// Each op maps to its endpoint; the API key + URLs stay server-side.
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SdParams {
|
||||
negativePrompt?: string;
|
||||
strength?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
guidanceScale?: number;
|
||||
}
|
||||
|
||||
function sanitizeParams(raw: unknown): SdParams {
|
||||
const p = (raw ?? {}) as Record<string, unknown>;
|
||||
const out: SdParams = {};
|
||||
if (typeof p.negativePrompt === "string" && p.negativePrompt.trim())
|
||||
out.negativePrompt = p.negativePrompt.trim().slice(0, 300);
|
||||
const strength = Number(p.strength);
|
||||
if (Number.isFinite(strength)) out.strength = Math.max(0, Math.min(1, strength));
|
||||
const steps = Number(p.steps);
|
||||
if (Number.isFinite(steps)) out.steps = Math.max(1, Math.min(60, Math.round(steps)));
|
||||
const guidance = Number(p.guidanceScale);
|
||||
if (Number.isFinite(guidance)) out.guidanceScale = Math.max(1, Math.min(20, guidance));
|
||||
const seed = Number(p.seed);
|
||||
if (Number.isFinite(seed)) out.seed = Math.max(0, Math.min(2_147_483_647, Math.round(seed)));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function editRunPod(
|
||||
op: { type?: string; prompt?: string; factor?: number },
|
||||
imageBase64: string,
|
||||
instruction: string,
|
||||
maskBase64: string | undefined,
|
||||
rawParams: unknown,
|
||||
): Promise<EditResult> {
|
||||
const params = sanitizeParams(rawParams);
|
||||
switch (op.type) {
|
||||
case "remove-background":
|
||||
// U²-Net via rembg (#6) — a real endpoint replacing the flaky in-browser remover.
|
||||
return rpRemoveBackground(imageBase64);
|
||||
case "restore":
|
||||
// Real-ESRGAN (#7) with the GFPGAN face pass = "Restore & Enhance".
|
||||
return rpUpscale(imageBase64, 4, true);
|
||||
case "upscale":
|
||||
return rpUpscale(imageBase64, op.factor === 4 ? 4 : 2, false);
|
||||
case "colorize":
|
||||
// The dedicated DDColor endpoint kept hard-crashing (modelscope). Route
|
||||
// colorize through the img2img model as an instruction instead.
|
||||
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params });
|
||||
case "prompt":
|
||||
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params }); // SD 3.5 img2img (#10)
|
||||
case "replace-sky":
|
||||
// True sky replacement is masked inpaint (#9). Without a mask (no in-app sky
|
||||
// segmentation yet) degrade to a low-strength img2img (#10) so the foreground
|
||||
// is mostly preserved.
|
||||
if (maskBase64)
|
||||
return rpInpaint({
|
||||
imageB64: imageBase64,
|
||||
maskB64: maskBase64,
|
||||
prompt: instruction,
|
||||
...params,
|
||||
});
|
||||
return rpImg2Img({
|
||||
imageB64: imageBase64,
|
||||
prompt: instruction,
|
||||
...params,
|
||||
strength: params.strength ?? 0.4,
|
||||
});
|
||||
case "magic-eraser":
|
||||
case "generative-fill":
|
||||
// SD 3.5 masked inpaint (#9) — white in the mask = the region to regenerate.
|
||||
if (!maskBase64) throw new AiError("This edit needs a mask/selection.", 400);
|
||||
return rpInpaint({
|
||||
imageB64: imageBase64,
|
||||
maskB64: maskBase64,
|
||||
prompt: instruction,
|
||||
...params,
|
||||
});
|
||||
default:
|
||||
throw new AiError("Unsupported operation.", 400);
|
||||
}
|
||||
}
|
||||
|
||||
class AiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: local Stable Diffusion (Automatic1111 / Forge / SD.Next img2img API)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editLocal(instruction: string, imageBase64: string): Promise<EditResult> {
|
||||
const base = process.env.LOCAL_SD_URL;
|
||||
if (!base || !/^https?:\/\//i.test(base)) {
|
||||
throw new AiError("LOCAL_SD_URL is not a valid http(s) URL.", 500);
|
||||
}
|
||||
const url = `${base.replace(/\/$/, "")}/sdapi/v1/img2img`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
init_images: [imageBase64],
|
||||
prompt: instruction,
|
||||
denoising_strength: Number(process.env.LOCAL_SD_DENOISE ?? 0.55),
|
||||
steps: Number(process.env.LOCAL_SD_STEPS ?? 25),
|
||||
cfg_scale: 7,
|
||||
sampler_name: process.env.LOCAL_SD_SAMPLER || "Euler a",
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new AiError("Could not reach your local Stable Diffusion server (LOCAL_SD_URL).", 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new AiError(`Local SD server error (${res.status}).`, 502);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as { images?: string[] } | null;
|
||||
const out = data?.images?.[0];
|
||||
if (!out) throw new AiError("Local SD server did not return an image.", 502);
|
||||
// A1111 returns raw base64 PNG (no data: prefix).
|
||||
return { imageBase64: out.includes(",") ? out.split(",")[1]! : out, mimeType: "image/png" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: Hugging Face Inference API — instruction image editing.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editHuggingFace(instruction: string, imageBase64: string): Promise<EditResult> {
|
||||
const token = process.env.HF_API_TOKEN;
|
||||
if (!token) throw new AiError("HF_API_TOKEN is not set.", 500);
|
||||
const model = process.env.HF_IMAGE_MODEL || "timbrooks/instruct-pix2pix";
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
// Wait for the model to warm up instead of a fast 503.
|
||||
"x-wait-for-model": "true",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: imageBase64,
|
||||
parameters: { prompt: instruction, guidance_scale: 7, image_guidance_scale: 1.5 },
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new AiError("Could not reach the Hugging Face Inference API.", 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Truncated on purpose — never surface a full upstream body.
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||
if (res.status === 503) throw new AiError("The model is loading — try again in ~20s.", 503);
|
||||
throw new AiError(`Hugging Face error (${res.status}). ${detail}`, 502);
|
||||
}
|
||||
// Success returns raw image bytes.
|
||||
const outMime = res.headers.get("content-type") || "image/png";
|
||||
if (outMime.startsWith("application/json")) {
|
||||
const j = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new AiError(
|
||||
j?.error ? `Hugging Face: ${j.error}` : "Hugging Face returned no image.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
return { imageBase64: Buffer.from(buf).toString("base64"), mimeType: outMime };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: Google Gemini image model (needs a billed key for image output).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editGemini(
|
||||
instruction: string,
|
||||
imageBase64: string,
|
||||
safeMime: string,
|
||||
): Promise<EditResult> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) throw new AiError("GEMINI_API_KEY is not set.", 500);
|
||||
const model = process.env.GEMINI_IMAGE_MODEL || "gemini-2.5-flash-image";
|
||||
const prompt = `Edit this image as follows: ${instruction}. Preserve realism unless explicitly asked otherwise.`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-goog-api-key": apiKey },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ inlineData: { mimeType: safeMime, data: imageBase64 } },
|
||||
{ text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: { responseModalities: ["IMAGE"] },
|
||||
}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new AiError("Could not reach the AI service.", 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Truncated on purpose — never surface a full upstream body.
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||
throw new AiError(`AI service error (${res.status}). ${detail}`, 502);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as GeminiResponse | null;
|
||||
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
||||
const imgPart = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
|
||||
const out = imgPart?.inlineData?.data ?? imgPart?.inline_data?.data;
|
||||
if (!out)
|
||||
throw new AiError(
|
||||
"The model did not return an image (the free Gemini tier has no image output — use LOCAL_SD_URL or HF_API_TOKEN instead).",
|
||||
502,
|
||||
);
|
||||
const outMime = imgPart?.inlineData?.mimeType ?? imgPart?.inline_data?.mime_type ?? "image/png";
|
||||
return { imageBase64: out, mimeType: outMime };
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
text?: string;
|
||||
inlineData?: { mimeType?: string; data?: string };
|
||||
inline_data?: { mime_type?: string; data?: string };
|
||||
}
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpTilt } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // cold-start tilt worker can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Camera-tilt estimation proxy. Accepts a base64 image and returns
|
||||
* {rollDegrees, pitchDegrees, fovDegrees} from the RunPod tilt endpoint
|
||||
* (RUNPOD_TILT_URL) so the editor can auto-straighten.
|
||||
*
|
||||
* POST { image } -> { rollDegrees, pitchDegrees, fovDegrees }
|
||||
* Auth: session-gated. Rate limit: 30/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "tilt");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
let body: { image?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const image = typeof body.image === "string" ? body.image : "";
|
||||
if (!image) return NextResponse.json({ error: "Missing image." }, { status: 400 });
|
||||
if (image.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: "Image too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
const tilt = await rpTilt(image);
|
||||
return NextResponse.json(tilt);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Tilt estimate failed.";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpTranscribe } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // cold-start STT worker can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Speech-to-text proxy for voice annotations. Accepts base64 WAV (16 kHz mono
|
||||
* PCM16, produced in-browser) and returns the transcript. Calls the RunPod
|
||||
* voice-to-text endpoint (RUNPOD_STT_URL) — the key stays server-side.
|
||||
*
|
||||
* POST { audio, language? } -> { transcript, segments? }
|
||||
* Auth: session-gated. Rate limit: 20/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 8_000_000; // ~6 MB decoded WAV — stays under serverless body limits
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "transcribe");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
let body: { audio?: unknown; language?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const audio = typeof body.audio === "string" ? body.audio : "";
|
||||
const language = typeof body.language === "string" ? body.language : undefined;
|
||||
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
|
||||
if (audio.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { transcript, segments } = await rpTranscribe(audio, { language, punctuation: true });
|
||||
return NextResponse.json({ transcript, segments });
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Transcription failed.";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,21 @@
|
||||
|
||||
.dash-content { padding: 22px 28px 40px; width: 100%; }
|
||||
.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; }
|
||||
@@ -1383,4 +1398,123 @@
|
||||
.dash-root .nl-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================================
|
||||
Smart Gallery — the embedded @photo-gallery/sdk surface.
|
||||
The SDK is themed entirely through the token map in lib/gallery-api.ts
|
||||
(--apg-* -> this file's own vars), so the rules below only handle the
|
||||
host chrome: sizing, the demo banner, and the load/error placeholders.
|
||||
========================================================================= */
|
||||
|
||||
/* The gallery is the one view that wants the whole viewport: it has its own sidebar, toolbar and
|
||||
scrollers, so any height we leave on the table is wasted chrome. The old big PageHead cost ~90px;
|
||||
a slim header (~40px) + compact banner + tight gaps hand almost all of that back to the shell.
|
||||
96px = the dashboard's top padding + the slim header row; `.gal-shell` (flex:1; min-height:0)
|
||||
consumes whatever is left after the header and the optional demo banner. */
|
||||
.dash-root .gal { display: flex; flex-direction: column; gap: 10px; height: calc(100vh - 96px); min-height: 700px; }
|
||||
|
||||
/* Slim inline header — replaces the tall PageHead. One row, ~40px, so the shell keeps the height. */
|
||||
.dash-root .gal-head { display: flex; align-items: center; gap: 10px; min-height: 36px; flex: 0 0 auto; }
|
||||
.dash-root .gal-head-ic { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); flex: 0 0 auto; }
|
||||
.dash-root .gal-head-title { font-size: 16px; font-weight: 700; line-height: 1.1; margin: 0; }
|
||||
.dash-root .gal-head-sub { color: var(--muted); font-size: 12.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
@media (max-width: 720px) { .dash-root .gal-head-sub { display: none; } }
|
||||
|
||||
/* `.view` sets `z-index: 1`, which makes it a stacking context and traps the SDK's
|
||||
full-screen overlays (lightbox z1000, editors z1050, camera z1080, modals z1100)
|
||||
underneath the topbar's `z-index: 20`. Opting this one view out of the stacking
|
||||
context lets those overlays cover the whole dashboard, as they must. The view still
|
||||
paints above the ambient `.dash-content::before` glow because it follows it in the DOM. */
|
||||
.dash-root .view.gal { z-index: auto; }
|
||||
|
||||
/* Compact single-line demo banner (~34px). Truncates rather than wrapping so it never steals a
|
||||
second row of height from the shell. */
|
||||
.dash-root .gal-banner { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 6px 12px; border-radius: 11px; border: 1px solid var(--border); background: color-mix(in srgb, var(--orange) 9%, var(--panel-2)); color: var(--text-2); font-size: 12px; font-weight: 500; flex: 0 0 auto; white-space: nowrap; overflow: hidden; }
|
||||
.dash-root .gal-banner span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-root .gal-banner svg { color: var(--orange); flex: 0 0 auto; }
|
||||
|
||||
/* The gallery's own viewport. `overflow: hidden` keeps the SDK's internal scrollers
|
||||
in charge; its full-screen overlays (lightbox/editor/camera) are position:fixed
|
||||
and deliberately escape this box to cover the whole dashboard. */
|
||||
.dash-root .gal-shell { flex: 1; min-height: 0; position: relative; border-radius: 18px; border: 1px solid var(--border); background: var(--panel-2); overflow: hidden; box-shadow: var(--card-hi), 0 1px 2px rgba(0, 0, 0, 0.18); }
|
||||
.dash-root[data-theme="dark"] .gal-shell { border: 0.5px solid #452b1a; border-radius: 20px; }
|
||||
|
||||
/* The SDK's embedded root fills this box. (--apg-overlay-top is set from the
|
||||
component's `style` prop — the SDK writes an inline default that a stylesheet
|
||||
rule could not override.) */
|
||||
.dash-root .gal-shell .apg { height: 100%; }
|
||||
|
||||
/* ---- Fullscreen (the SDK puts `.apg--fullscreen` on its root: position:fixed; inset:0) ----
|
||||
A position:fixed box is only clipped by an ancestor that is its CONTAINING BLOCK, which
|
||||
`overflow`/`border-radius`/`box-shadow` alone never create — only transform / filter /
|
||||
perspective / backdrop-filter / will-change / contain do. Nothing on the path
|
||||
(.dash-content > .view.gal > .gal-shell) uses any of those: `.view`'s `ds-fade` animates
|
||||
opacity only, and `.view.gal` already drops the `z-index: 1` stacking context. So the
|
||||
fullscreen root does escape today — these rules make that survive an edit above. */
|
||||
|
||||
/* `.dash-root .gal-shell .apg` (0,3,0) would otherwise out-specify the SDK's own sizing; with
|
||||
inset:0 driving the box, height must get out of the way. */
|
||||
.dash-root .gal-shell .apg.apg--fullscreen {
|
||||
height: auto;
|
||||
/* Above the topbar (z-index: 20) and the sidebar, below the SDK's own overlays (1000+). */
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* Belt and braces: if a future rule ever DOES make `.gal-shell` a containing block, an
|
||||
`overflow: hidden` on it would crop the fullscreen root to the embedded box. Drop the clip
|
||||
(and the rounded corner it exists to enforce) for exactly as long as fullscreen is on. */
|
||||
.dash-root .gal-shell:has(.apg--fullscreen) { overflow: visible; }
|
||||
|
||||
.dash-root .gal-placeholder { height: 100%; min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; padding: 28px; }
|
||||
.dash-root .gal-placeholder-ic { width: 66px; height: 66px; border-radius: 20px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); }
|
||||
.dash-root .gal-placeholder p { color: var(--muted); font-size: 13px; max-width: 420px; }
|
||||
.dash-root .gal-placeholder h3 { font-size: 16px; font-weight: 700; }
|
||||
.dash-root .gal-placeholder-error .gal-placeholder-ic { background: color-mix(in srgb, var(--red) 88%, #000); box-shadow: 0 10px 28px -12px color-mix(in srgb, var(--red) 60%, transparent); }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
/* Narrower chrome: a little less top offset, and a smaller floor so short viewports still work. */
|
||||
.dash-root .gal { height: calc(100vh - 84px); min-height: 560px; }
|
||||
}
|
||||
|
||||
/* ---- Org Settings → Integrations ---- */
|
||||
.dash-root .settings-section { margin-top: 8px; }
|
||||
.dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
|
||||
.dash-root .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
|
||||
.dash-root .settings-card { border: 1px solid var(--border); background: var(--panel); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .settings-card.is-soon { opacity: 0.6; }
|
||||
.dash-root .settings-card-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.dash-root .settings-card-ic { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||
.dash-root .settings-card-titles { flex: 1 1 auto; min-width: 0; }
|
||||
.dash-root .settings-card-name { font-size: 15px; font-weight: 700; color: var(--text); }
|
||||
.dash-root .settings-card-sub { font-weight: 500; color: var(--muted); }
|
||||
.dash-root .settings-card-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
|
||||
.dash-root .settings-card-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.dash-root .settings-kv { display: grid; gap: 10px; margin: 0; }
|
||||
.dash-root .settings-kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||
.dash-root .settings-kv > div:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
.dash-root .settings-kv dt { font-size: 12.5px; color: var(--muted); }
|
||||
.dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
|
||||
.dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; }
|
||||
.dash-root .settings-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
||||
|
||||
/* ---- Global conversation search (topbar) ---- */
|
||||
.dash-root .gs-wrap { position: relative; }
|
||||
.dash-root .gs-field { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 42vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
|
||||
.dash-root .gs-field:focus-within { border-color: var(--orange); }
|
||||
.dash-root .gs-input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; }
|
||||
.dash-root .gs-input::placeholder { color: var(--muted); }
|
||||
.dash-root .gs-pop { position: absolute; top: calc(100% + 6px); right: 0; width: 420px; max-width: 90vw; max-height: 420px; overflow-y: auto; padding: 6px; border-radius: 14px; border: 1px solid var(--border); background: var(--panel); box-shadow: 0 24px 60px -20px rgba(0,0,0,0.55); z-index: 60; }
|
||||
.dash-root .gs-empty { padding: 14px; font-size: 13px; color: var(--muted); text-align: center; }
|
||||
.dash-root .gs-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: none; border-radius: 10px; cursor: pointer; text-align: left; color: var(--text); }
|
||||
.dash-root .gs-row:hover { background: var(--panel-2); }
|
||||
.dash-root .gs-ic { flex: 0 0 auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||
.dash-root .gs-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.dash-root .gs-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .gs-snippet { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; }
|
||||
.dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
||||
@media (max-width: 720px) { .dash-root .gs-field { width: 160px; } }
|
||||
|
||||
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
|
||||
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
|
||||
|
||||
@@ -15,8 +15,12 @@ import { Support } from "./support";
|
||||
import { Rules } from "./rules";
|
||||
import { 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return esc.replace(/<em>/g, "<em>").replace(/<\/em>/g, "</em>");
|
||||
}
|
||||
|
||||
export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||
const search = useGlobalSearch();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
const t = setTimeout(() => {
|
||||
search(term)
|
||||
.then((r) => { if (alive) setResults(r); })
|
||||
.catch(() => { if (alive) setResults([]); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
}, 220);
|
||||
return () => { alive = false; clearTimeout(t); };
|
||||
}, [q, search]);
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, []);
|
||||
|
||||
function pick(r: SearchResult) {
|
||||
onNavigate(r.surface, r.threadId);
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gs-wrap" ref={wrapRef}>
|
||||
<div className="gs-field">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
className="gs-input"
|
||||
placeholder="Search conversations…"
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
aria-label="Search conversations"
|
||||
/>
|
||||
</div>
|
||||
{open && q.trim() ? (
|
||||
<div className="gs-pop">
|
||||
{loading && results.length === 0 ? <div className="gs-empty">Searching…</div> : null}
|
||||
{!loading && results.length === 0 ? <div className="gs-empty">No matches.</div> : null}
|
||||
{results.map((r) => (
|
||||
<button key={r.interactionId} type="button" className="gs-row" onClick={() => pick(r)}>
|
||||
<span className="gs-ic"><Icon name={r.surface === "inbox" ? "mail" : "send"} size={14} /></span>
|
||||
<span className="gs-main">
|
||||
<span className="gs-title">{r.title}</span>
|
||||
<span className="gs-snippet" dangerouslySetInnerHTML={{ __html: safeSnippet(r.snippet) }} />
|
||||
</span>
|
||||
<span className="gs-surface">{r.surface === "inbox" ? "Mail" : "Chat"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
// The CRM Inbox, rendered by @insignia/iios-messaging-ui instead of the bespoke in-CRM inbox.
|
||||
// Live = the be-crm data door (CrmInboxAdapter over crm.inbox.* + crm.mail.*); demo = the SDK's
|
||||
// MockInboxAdapter.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { InboxProvider, Inbox as SdkInbox, type InboxAdapter } from "@insignia/iios-messaging-ui";
|
||||
import { MockInboxAdapter } from "@insignia/iios-messaging-ui/adapters/mock-inbox";
|
||||
import "@insignia/iios-messaging-ui/styles.css";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
import { CrmInboxAdapter } from "@/lib/crm-inbox-adapter";
|
||||
import type { DataDoor } from "@/lib/crm-messaging-adapter";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function InboxSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||
return (
|
||||
<div className="view">
|
||||
{!SHELL && (
|
||||
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
|
||||
Demo mode — running on the SDK's mock inbox adapter.
|
||||
</div>
|
||||
)}
|
||||
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox focusThreadId={focusThreadId} /> : <DemoInbox focusThreadId={focusThreadId} />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoInbox({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
|
||||
return (
|
||||
<InboxProvider adapter={adapter}>
|
||||
<SdkInbox focusThreadId={focusThreadId} />
|
||||
</InboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveInbox({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const { sdk } = useAppShell();
|
||||
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
|
||||
return (
|
||||
<InboxProvider adapter={adapter}>
|
||||
<SdkInbox focusThreadId={focusThreadId} />
|
||||
</InboxProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Inbox — the ONE unified communication surface. It lists everything
|
||||
// IIOS surfaces for you (mentions, needs-reply, system alerts, support
|
||||
// updates, …) AND the mail behind them: click an item tied to a thread
|
||||
// and its conversation opens on the right to read + reply. Compose new
|
||||
// mail from here too. Items come from crm.inbox.*; threads from crm.mail.*.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Btn, Icon, PageHead, Pill, useToast } from "./ui";
|
||||
import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api";
|
||||
import { MailReader, NewMailModal } from "./mail";
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
MAIL: "Mail",
|
||||
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
|
||||
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
|
||||
};
|
||||
const FILTERS: { value: InboxState; label: string }[] = [
|
||||
{ value: "OPEN", label: "Open" }, { value: "SNOOZED", label: "Snoozed" }, { value: "DONE", label: "Done" }, { value: "ARCHIVED", label: "Archived" },
|
||||
];
|
||||
|
||||
export function Inbox() {
|
||||
const [filter, setFilter] = useState<InboxState>("OPEN");
|
||||
const inbox = useInboxData(filter);
|
||||
const toast = useToast();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!selectedId || !inbox.items.some((i) => i.id === selectedId)) && inbox.items[0]) setSelectedId(inbox.items[0].id);
|
||||
}, [inbox.items, selectedId]);
|
||||
|
||||
const selected = inbox.items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="view">
|
||||
<PageHead
|
||||
eyebrow="Communication" title="Inbox" subtitle="Mentions, messages, system alerts and mail — all in one place" icon="bell"
|
||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New mail</Btn>}
|
||||
/>
|
||||
{!inbox.live && (
|
||||
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
|
||||
Demo mode — running on mock data. It goes live once the Shell + be-crm are connected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 14, flexWrap: "wrap" }}>
|
||||
{FILTERS.map((f) => (
|
||||
<Btn key={f.value} variant={filter === f.value ? "primary" : "outline"} onClick={() => setFilter(f.value)}>{f.label}</Btn>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
|
||||
{/* Left — the unified item list */}
|
||||
<aside style={{ width: 360, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
|
||||
{inbox.loading && <div style={{ padding: 20, color: "var(--muted)" }}>Loading…</div>}
|
||||
{!inbox.loading && inbox.items.length === 0 && (
|
||||
<div style={{ padding: 28, color: "var(--muted)", textAlign: "center" }}>Nothing here — you're all caught up 🎉</div>
|
||||
)}
|
||||
{inbox.items.map((it) => (
|
||||
<ItemRow key={it.id} it={it} active={it.id === selectedId} onClick={() => setSelectedId(it.id)} />
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* Right — read the mail behind the item, or the item detail */}
|
||||
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "var(--bg)" }}>
|
||||
{selected ? (
|
||||
<Detail
|
||||
it={selected}
|
||||
onError={(m) => toast.push({ tone: "error", title: "Failed", desc: m })}
|
||||
onDone={() => inbox.transition(selected.id, "DONE")}
|
||||
onSnooze={() => inbox.transition(selected.id, "SNOOZED")}
|
||||
onArchive={() => inbox.transition(selected.id, "ARCHIVED")}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
|
||||
<Icon name="bell" size={38} /><p>Select an item to read</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<NewMailModal
|
||||
open={newOpen} onClose={() => setNewOpen(false)}
|
||||
onSent={() => { setNewOpen(false); inbox.refetch(); toast.push({ tone: "success", title: "Sent" }); }}
|
||||
onError={(m) => toast.push({ tone: "error", title: "Couldn't send", desc: m })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; onClick: () => void }) {
|
||||
const isMention = it.kind === "MENTION";
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex", gap: 10, alignItems: "flex-start", width: "100%", textAlign: "left",
|
||||
padding: "13px 16px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
|
||||
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
|
||||
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Pill tone={isMention ? "warn" : "muted"}>{KIND_LABEL[it.kind] ?? it.kind}</Pill>
|
||||
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
|
||||
</div>
|
||||
{it.summary && <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 3, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.summary}</div>}
|
||||
</div>
|
||||
{it.state !== "OPEN" && <Pill tone="muted">{it.state.toLowerCase()}</Pill>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ it, onError, onDone, onSnooze, onArchive }: {
|
||||
it: UiInboxItem; onError: (m: string) => void; onDone: () => void; onSnooze: () => void; onArchive: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Item actions bar — only for real inbox work-items. Mail isn't an inbox item
|
||||
(no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */}
|
||||
{it.state === "OPEN" && it.kind !== "MAIL" && (
|
||||
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
|
||||
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
|
||||
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
|
||||
<Btn variant="ghost" icon="x" onClick={onArchive}>Archive</Btn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{it.threadId ? (
|
||||
// A message/mail item → open the conversation to read + reply.
|
||||
// key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the
|
||||
// variables — so switching items must remount MailReader to load the new thread's history.
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
|
||||
</div>
|
||||
) : (
|
||||
// A non-threaded item (e.g. a system alert) → show its detail.
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 22 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>{it.title}</div>
|
||||
{it.summary && <div style={{ color: "var(--muted)", fontSize: 14, lineHeight: 1.55 }}>{it.summary}</div>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Mail components used INSIDE the Inbox (not a separate tab).
|
||||
// The Inbox is the one unified surface — mentions, system messages
|
||||
// and mail all live there. These render the mail body + reply, and
|
||||
// compose a new message. HTML bodies render in a sandboxed iframe.
|
||||
// ============================================================
|
||||
|
||||
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui";
|
||||
import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api";
|
||||
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
|
||||
|
||||
const timeOf = (iso?: string) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
|
||||
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
|
||||
};
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */
|
||||
function MailAttachmentView({ att }: { att: MailAttachment }) {
|
||||
const getUrl = useDownloadUrl();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [att.contentRef, att.mimeType, getUrl]);
|
||||
|
||||
const label = att.filename || "Attachment";
|
||||
if (isImage(att.mimeType)) {
|
||||
return url
|
||||
? <a href={url} target="_blank" rel="noreferrer" style={{ display: "inline-block" }}><img src={url} alt={label} style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, border: "1px solid var(--border)" }} /></a>
|
||||
: <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading image…</div>;
|
||||
}
|
||||
return (
|
||||
<a href={url ?? "#"} target="_blank" rel="noreferrer"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--text)", textDecoration: "none", maxWidth: 320 }}>
|
||||
<Icon name="paperclip" size={18} />
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
{att.sizeBytes > 0 && <span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(att.sizeBytes)}</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small staged-file chip shown in a composer before send, with a remove button. */
|
||||
function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) {
|
||||
return (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 10px", borderRadius: 999, background: "var(--panel-2)", border: "1px solid var(--border)", fontSize: 13 }}>
|
||||
<Icon name="paperclip" size={14} />
|
||||
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.filename}</span>
|
||||
<span style={{ color: "var(--muted)" }}>{fmtBytes(file.sizeBytes)}</span>
|
||||
<button onClick={onRemove} title="Remove" style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", padding: 0, lineHeight: 1 }}>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */
|
||||
export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) {
|
||||
const t = useMailThread(threadId);
|
||||
const upload = useUploadAttachment();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try { setStaged(await upload(file)); }
|
||||
catch (err) { onError((err as Error).message); }
|
||||
finally { setUploading(false); }
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
const text = draft.trim();
|
||||
if ((!text && !staged) || sending) return;
|
||||
const att = staged ?? undefined;
|
||||
setDraft(""); setStaged(null); setSending(true);
|
||||
try { await t.reply(text, att); }
|
||||
catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); }
|
||||
finally { setSending(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
||||
<header style={{ padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15 }}>{subject || "(no subject)"}</div>
|
||||
</header>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading…</div>}
|
||||
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages.</div>}
|
||||
{t.messages.map((m) => (
|
||||
<div key={m.interactionId} style={{ border: "1px solid var(--border)", borderRadius: 12, background: "var(--panel)", overflow: "hidden" }}>
|
||||
<div style={{ padding: "7px 12px", borderBottom: "1px solid var(--border)", display: "flex", justifyContent: "space-between", fontSize: 12, color: "var(--muted)" }}>
|
||||
<span>{m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""}</span>
|
||||
<span>{timeOf(m.occurredAt)}</span>
|
||||
</div>
|
||||
{m.html
|
||||
? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} />
|
||||
: m.text
|
||||
? <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>
|
||||
: null}
|
||||
{m.attachment && <div style={{ padding: 12, paddingTop: m.html || m.text ? 0 : 12 }}><MailAttachmentView att={m.attachment} /></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}>
|
||||
{staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
|
||||
<Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
|
||||
<input
|
||||
value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
|
||||
placeholder="Reply…" style={inputStyle}
|
||||
/>
|
||||
<Btn icon="send" onClick={() => void reply()} disabled={sending || (!draft.trim() && !staged)}>Reply</Btn>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compose a new message — in-app (to a person) or external (to an email). */
|
||||
export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) {
|
||||
const compose = useMailCompose(onSent);
|
||||
const upload = useUploadAttachment();
|
||||
const [mode, setMode] = useState<"internal" | "external">("internal");
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [staged, setStaged] = useState<UploadedAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); setStaged([]); setUploading(false); } }, [open]);
|
||||
|
||||
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(files.map((f) => upload(f)));
|
||||
setStaged((s) => [...s, ...uploaded].slice(0, 10));
|
||||
} catch (err) { onError((err as Error).message); }
|
||||
finally { setUploading(false); }
|
||||
}
|
||||
|
||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy && !uploading;
|
||||
|
||||
async function send() {
|
||||
if (!canSend) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim(), staged.length ? staged : undefined);
|
||||
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), staged.length ? { attachments: staged } : undefined);
|
||||
} catch (e) { onError((e as Error).message); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open} onClose={onClose} title="New message" subtitle={mode === "internal" ? "To a team member or client (in-app)" : "To an email address"} icon="chat"
|
||||
footer={<>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn icon="send" onClick={() => void send()} disabled={!canSend}>{busy ? "Sending…" : "Send"}</Btn>
|
||||
</>}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||
<Btn variant={mode === "internal" ? "primary" : "outline"} onClick={() => { setMode("internal"); setRecipient(""); }}>In-app</Btn>
|
||||
<Btn variant={mode === "external" ? "primary" : "outline"} onClick={() => { setMode("external"); setRecipient(""); }}>Email</Btn>
|
||||
</div>
|
||||
|
||||
{mode === "internal" ? (
|
||||
<Field label="To (person)">
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
|
||||
<div style={{ maxHeight: 180, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No people found.</div>}
|
||||
{filtered.map((p: MailPerson) => (
|
||||
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: recipient === p.id ? "var(--panel-2)" : "transparent" }}>
|
||||
<input type="radio" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
|
||||
<Avatar initials={(p.name.split(/\s+/).map((s) => s[0]).join("").slice(0, 2) || "?").toUpperCase()} size={26} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
||||
<span style={{ flex: 1 }}>{p.name}</span>
|
||||
<Pill tone="muted">{p.kind}</Pill>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="To (email)">
|
||||
<input value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" style={inputStyle} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
|
||||
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
|
||||
|
||||
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
|
||||
Attach button's click via label→input association and open the picker erratically. */}
|
||||
<div className="ds-field">
|
||||
<span className="ds-field-lbl">Attachments</span>
|
||||
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
|
||||
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
|
||||
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
|
||||
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
|
||||
// demo path = the SDK's own MockAdapter.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
|
||||
import "@insignia/iios-messaging-ui/styles.css";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
import { useRealtime } from "@/lib/realtime";
|
||||
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||
return (
|
||||
<div className="view">
|
||||
{!SHELL && (
|
||||
<div
|
||||
style={{
|
||||
margin: "0 0 14px",
|
||||
padding: "8px 14px",
|
||||
borderRadius: 10,
|
||||
background: "var(--panel-2)",
|
||||
color: "var(--muted)",
|
||||
fontSize: 13,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
Demo mode — running on the SDK's mock adapter.
|
||||
</div>
|
||||
)}
|
||||
<div className="miu-host">{SHELL ? <LiveHost focusThreadId={focusThreadId} /> : <DemoHost focusThreadId={focusThreadId} />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
|
||||
// (Rules-of-Hooks safe — the other branch never renders).
|
||||
function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
|
||||
return (
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<SdkMessenger focusThreadId={focusThreadId} />
|
||||
</MessagingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const socket = useRealtime();
|
||||
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
|
||||
const adapter = useMemo<MessagingAdapter | null>(
|
||||
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
|
||||
[sdk, user?.id, socket],
|
||||
);
|
||||
if (!adapter) return <div className="miu-empty">Loading…</div>;
|
||||
return (
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<SdkMessenger focusThreadId={focusThreadId} />
|
||||
</MessagingProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Messenger — internal team + client chat, powered by IIOS via
|
||||
// the be-crm data door (crm.messenger.*). Conversation list ⇄
|
||||
// thread view + composer, with a "new chat" people picker that
|
||||
// creates a DM (1 person) or group (2+). DM-vs-group and who-can-
|
||||
// chat are enforced server-side by IIOS/OPA; this is just UI.
|
||||
// Live messages, typing, read receipts and reactions come over the
|
||||
// IIOS socket (Shell mode); mock keeps the demo working offline.
|
||||
// ============================================================
|
||||
|
||||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
||||
import { useMessengerData, useThread, useGroupSettings, type Membership, type UiAttachment, type UiConversation, type UiMember, type UiMessage, type UiPerson } from "@/lib/messenger-api";
|
||||
import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
|
||||
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
|
||||
|
||||
const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
||||
|
||||
/** Renders a message attachment — an inline image thumbnail, or a downloadable file chip. */
|
||||
function AttachmentView({ att }: { att: UiAttachment }) {
|
||||
const getUrl = useDownloadUrl();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [att.contentRef, att.mimeType, getUrl]);
|
||||
|
||||
if (isImage(att.mimeType)) {
|
||||
return url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<a href={url} target="_blank" rel="noreferrer"><img src={url} alt="attachment" style={{ maxWidth: 240, maxHeight: 240, borderRadius: 10, display: "block", marginTop: 6, border: "1px solid var(--border)" }} /></a>
|
||||
) : <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 12 }}>Loading image…</div>;
|
||||
}
|
||||
return (
|
||||
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}>
|
||||
<Icon name="paperclip" size={18} />
|
||||
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
|
||||
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const initialsOf = (name: string) =>
|
||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
const timeOf = (iso?: string) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
const GROUP_GRAD = "linear-gradient(135deg,#6366f1,#8b5cf6)";
|
||||
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
|
||||
const REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
|
||||
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
|
||||
};
|
||||
|
||||
export function Messenger() {
|
||||
// One shared IIOS socket for the whole panel (live in Shell mode; no-op in mock).
|
||||
return (
|
||||
<MessengerSocketProvider>
|
||||
<MessengerPanel />
|
||||
</MessengerSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MessengerPanel() {
|
||||
const m = useMessengerData();
|
||||
const toast = useToast();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!selected || !m.conversations.some((c) => c.threadId === selected)) && m.conversations[0]) {
|
||||
setSelected(m.conversations[0].threadId);
|
||||
}
|
||||
}, [m.conversations, selected]);
|
||||
|
||||
const current = m.conversations.find((c) => c.threadId === selected) ?? null;
|
||||
|
||||
return (
|
||||
<div className="view">
|
||||
<PageHead
|
||||
eyebrow="Communication" title="Messenger" subtitle="Chat with your team and clients — direct or in groups" icon="send"
|
||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New chat</Btn>}
|
||||
/>
|
||||
{!m.live && (
|
||||
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
|
||||
Demo mode — running on mock data. It goes live once the Shell + be-crm are connected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
|
||||
<aside style={{ width: 296, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
|
||||
{m.loading && <div style={{ padding: 16, color: "var(--muted)" }}>Loading…</div>}
|
||||
{!m.loading && m.conversations.length === 0 && (
|
||||
<div style={{ padding: 16, color: "var(--muted)" }}>No conversations yet. Start a new chat.</div>
|
||||
)}
|
||||
{m.conversations.map((c) => (
|
||||
<ConversationRow key={c.threadId} c={c} active={c.threadId === selected} onClick={() => setSelected(c.threadId)} />
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{current ? (
|
||||
<ThreadView key={current.threadId} conv={current} nameOf={m.nameOf} directory={m.directory} onError={(msg) => toast.push({ tone: "error", title: "Message failed", desc: msg })} />
|
||||
) : (
|
||||
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
|
||||
<Icon name="send" size={38} />
|
||||
<p>Select or start a conversation</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<NewChatModal
|
||||
open={newOpen} onClose={() => setNewOpen(false)} directory={m.directory}
|
||||
onCreate={async (ids, opts) => {
|
||||
try {
|
||||
const id = await m.openConversation(ids, opts);
|
||||
setSelected(id);
|
||||
setNewOpen(false);
|
||||
} catch (e) {
|
||||
toast.push({ tone: "error", title: "Couldn't start chat", desc: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationRow({ c, active, onClick }: { c: UiConversation; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex", gap: 10, alignItems: "center", width: "100%", textAlign: "left",
|
||||
padding: "10px 14px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
|
||||
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<Avatar initials={initialsOf(c.title)} size={38} gradient={c.membership === "group" ? GROUP_GRAD : undefined} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{c.title}</span>
|
||||
<span style={{ color: "var(--muted)", fontSize: 11, flexShrink: 0 }}>{timeOf(c.lastAt)}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
|
||||
<span style={{ color: "var(--muted)", fontSize: 12.5, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{c.lastMessage ?? "No messages yet"}
|
||||
</span>
|
||||
{c.unread > 0 && (
|
||||
<span style={{ background: "var(--orange)", color: "#fff", borderRadius: 999, fontSize: 11, padding: "1px 7px", flexShrink: 0 }}>{c.unread}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadView({ conv, nameOf, directory, onError }: { conv: UiConversation; nameOf: (id: string) => string; directory: UiPerson[]; onError: (m: string) => void }) {
|
||||
const t = useThread(conv.threadId);
|
||||
const socket = useMessengerSocket();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<UiMessage | null>(null);
|
||||
const [flashId, setFlashId] = useState<string | null>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const msgRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const typingSentAt = useRef(0);
|
||||
|
||||
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]);
|
||||
|
||||
const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]);
|
||||
const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]);
|
||||
|
||||
// Reply → focus the composer (bug: it didn't focus, forcing a manual click).
|
||||
function startReply(msg: UiMessage) {
|
||||
setReplyTo(msg);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
// Click a quoted message → scroll to the original and flash it.
|
||||
function jumpTo(id: string) {
|
||||
const el = msgRefs.current.get(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setFlashId(id);
|
||||
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200);
|
||||
}
|
||||
|
||||
const uploadAttachment = useUploadAttachment();
|
||||
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function onDraftChange(v: string) {
|
||||
setDraft(v);
|
||||
const now = Date.now();
|
||||
if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; }
|
||||
}
|
||||
|
||||
async function onPickFile(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try { setStaged(await uploadAttachment(file)); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const text = draft.trim();
|
||||
if ((!text && !staged) || sending) return; // allow an attachment with no text
|
||||
const parent = replyTo?.id;
|
||||
const att = staged;
|
||||
setDraft(""); setReplyTo(null); setStaged(null); setSending(true);
|
||||
try {
|
||||
await t.send(text, {
|
||||
...(parent ? { parentInteractionId: parent } : {}),
|
||||
...(att ? { attachment: { contentRef: att.contentRef, mimeType: att.mimeType, sizeBytes: att.sizeBytes } } : {}),
|
||||
});
|
||||
} catch (e) { setDraft(text); setStaged(att); onError((e as Error).message); }
|
||||
finally { setSending(false); }
|
||||
}
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const typingLabel = t.typingUserIds.length === 1
|
||||
? `${nameOf(t.typingUserIds[0])} is typing…`
|
||||
: t.typingUserIds.length > 1 ? "Several people are typing…" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
|
||||
<Avatar initials={initialsOf(conv.title)} size={34} gradient={conv.membership === "group" ? GROUP_GRAD : undefined} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{conv.title}</div>
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>
|
||||
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
|
||||
</div>
|
||||
</div>
|
||||
{conv.membership === "group" && (
|
||||
<button onClick={() => setSettingsOpen(true)} title="Group settings" style={{ ...actionBtnStyle, width: 34, height: 34 }}>
|
||||
<Icon name="settings" size={18} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{conv.membership === "group" && settingsOpen && (
|
||||
<GroupSettingsModal conv={conv} directory={directory} onClose={() => setSettingsOpen(false)} onError={onError} />
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 18, display: "flex", flexDirection: "column", gap: 10, background: "var(--bg)" }}>
|
||||
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading messages…</div>}
|
||||
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages yet — say hello 👋</div>}
|
||||
{t.messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id} msg={msg}
|
||||
parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined}
|
||||
seen={msg.id === lastMineId && t.seenIds.has(msg.id)}
|
||||
showStatus={msg.id === lastMineId}
|
||||
flash={flashId === msg.id}
|
||||
registerRef={(el) => { if (el) msgRefs.current.set(msg.id, el); else msgRefs.current.delete(msg.id); }}
|
||||
onReact={(emoji) => t.react(msg.id, emoji)}
|
||||
onReply={() => startReply(msg)}
|
||||
onQuoteClick={jumpTo}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: 18, padding: "0 18px", color: "var(--muted)", fontSize: 12, fontStyle: "italic" }}>{typingLabel}</div>
|
||||
|
||||
{replyTo && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--orange)", fontWeight: 600 }}>Replying to {replyTo.mine ? "yourself" : nameOf(replyTo.senderId ?? "")}</div>
|
||||
<div style={{ fontSize: 12.5, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{replyTo.text}</div>
|
||||
</div>
|
||||
<button onClick={() => setReplyTo(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Cancel reply">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{staged && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)" }}>
|
||||
<Icon name={isImage(staged.mimeType) ? "image" : "file"} size={16} />
|
||||
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>{staged.filename}</span>
|
||||
<span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(staged.sizeBytes)}</span>
|
||||
<button onClick={() => setStaged(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Remove attachment">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)", alignItems: "center" }}>
|
||||
<input ref={fileRef} type="file" hidden onChange={(e) => void onPickFile(e.target.files?.[0])} />
|
||||
<button onClick={() => fileRef.current?.click()} disabled={uploading} title="Attach a file" style={{ ...actionBtnStyle, width: 38, height: 38, flexShrink: 0, opacity: uploading ? 0.5 : 1 }}>
|
||||
{uploading ? "…" : "📎"}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft} onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
|
||||
placeholder="Type a message…" style={inputStyle}
|
||||
/>
|
||||
<Btn icon="send" onClick={() => void submit()} disabled={sending || (!draft.trim() && !staged)}>Send</Btn>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
msg, parent, seen, showStatus, flash, registerRef, onReact, onReply, onQuoteClick,
|
||||
}: {
|
||||
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; flash?: boolean;
|
||||
registerRef?: (el: HTMLElement | null) => void;
|
||||
onReact: (emoji: string) => void; onReply: () => void; onQuoteClick?: (id: string) => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [picker, setPicker] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={registerRef}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPicker(false); }}
|
||||
style={{
|
||||
alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column",
|
||||
alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative",
|
||||
borderRadius: 14, padding: 2, transition: "background 0.4s",
|
||||
background: flash ? "rgba(253,169,19,0.22)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => parent.id && onQuoteClick?.(parent.id)}
|
||||
title="Go to message"
|
||||
style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", border: "none", borderLeftWidth: 3, fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: "pointer", textAlign: "left" }}
|
||||
>
|
||||
<span style={{ opacity: 0.8 }}>↩ {parent.text}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
|
||||
{(msg.text || !msg.attachment) && (
|
||||
<div style={{
|
||||
background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)",
|
||||
padding: "8px 12px", borderRadius: 14,
|
||||
borderBottomRightRadius: msg.mine ? 4 : 14, borderBottomLeftRadius: msg.mine ? 14 : 4,
|
||||
border: msg.mine ? "none" : "1px solid var(--border)",
|
||||
whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14,
|
||||
}}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
{hover && (
|
||||
<div style={{ display: "flex", gap: 2, position: "relative" }}>
|
||||
<button onClick={() => setPicker((p) => !p)} title="React" style={actionBtnStyle}>🙂</button>
|
||||
<button onClick={onReply} title="Reply" style={actionBtnStyle}>↩</button>
|
||||
{picker && (
|
||||
<div style={{ position: "absolute", bottom: "100%", [msg.mine ? "right" : "left"]: 0, marginBottom: 4, display: "flex", gap: 2, padding: 4, borderRadius: 999, background: "var(--panel)", border: "1px solid var(--border)", boxShadow: "0 6px 20px rgba(0,0,0,0.35)", zIndex: 5 }}>
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button key={e} onClick={() => { onReact(e); setPicker(false); }} style={{ ...actionBtnStyle, fontSize: 16 }}>{e}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg.attachment && (
|
||||
<div style={{ marginTop: 4, display: "flex", justifyContent: msg.mine ? "flex-end" : "flex-start" }}>
|
||||
<AttachmentView att={msg.attachment} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.reactions && msg.reactions.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 4, marginTop: 3, flexWrap: "wrap" }}>
|
||||
{msg.reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji} onClick={() => onReact(r.emoji)}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 3, padding: "1px 7px", borderRadius: 999, fontSize: 12, cursor: "pointer",
|
||||
background: r.mine ? "rgba(253,169,19,0.18)" : "var(--panel-2)",
|
||||
border: `1px solid ${r.mine ? "var(--orange)" : "var(--border)"}`, color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<span>{r.emoji}</span><span style={{ color: "var(--muted)" }}>{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 10.5, color: "var(--muted)", marginTop: 2 }}>
|
||||
{timeOf(msg.at)}{showStatus && msg.mine ? ` · ${seen ? "Seen" : "Sent"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionBtnStyle: CSSProperties = {
|
||||
background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 8,
|
||||
width: 26, height: 26, display: "grid", placeItems: "center", cursor: "pointer", fontSize: 13, color: "var(--text)", padding: 0,
|
||||
};
|
||||
|
||||
function NewChatModal({
|
||||
open, onClose, directory, onCreate,
|
||||
}: {
|
||||
open: boolean; onClose: () => void; directory: UiPerson[];
|
||||
onCreate: (ids: string[], opts: { membership: Membership; subject?: string }) => Promise<void>;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => { if (!open) { setPicked([]); setSubject(""); setQ(""); setBusy(false); } }, [open]);
|
||||
|
||||
const membership: Membership = picked.length > 1 ? "group" : "dm";
|
||||
const filtered = directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : [...l, id]));
|
||||
|
||||
async function create() {
|
||||
if (!picked.length || busy) return;
|
||||
setBusy(true);
|
||||
await onCreate(picked, { membership, ...(membership === "group" && subject.trim() ? { subject: subject.trim() } : {}) });
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open} onClose={onClose} title="New conversation"
|
||||
subtitle={membership === "group" ? "Group chat" : "Direct message"} icon="send"
|
||||
footer={<>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn icon="send" onClick={() => void create()} disabled={!picked.length || busy}>{busy ? "Starting…" : "Start chat"}</Btn>
|
||||
</>}
|
||||
>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 10 }} />
|
||||
{membership === "group" && (
|
||||
<Field label="Group name (optional)">
|
||||
<input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Storm response" style={inputStyle} />
|
||||
</Field>
|
||||
)}
|
||||
<div style={{ maxHeight: 320, overflowY: "auto", marginTop: 8, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 10 }}>No people found.</div>}
|
||||
{filtered.map((p) => (
|
||||
<label key={p.id} style={{
|
||||
display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 10, cursor: "pointer",
|
||||
background: picked.includes(p.id) ? "var(--panel-2)" : "transparent",
|
||||
}}>
|
||||
<input type="checkbox" checked={picked.includes(p.id)} onChange={() => toggle(p.id)} />
|
||||
<Avatar initials={initialsOf(p.name)} size={30} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
||||
<span style={{ flex: 1 }}>{p.name}</span>
|
||||
<Pill tone="muted">{p.kind}</Pill>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Group settings: rename, member list with roles, add/remove — admin-gated (IIOS/OPA re-enforces). */
|
||||
function GroupSettingsModal({ conv, directory, onClose, onError }: {
|
||||
conv: UiConversation; directory: UiPerson[]; onClose: () => void; onError: (m: string) => void;
|
||||
}) {
|
||||
const g = useGroupSettings(conv.threadId);
|
||||
const [name, setName] = useState(conv.subject ?? "");
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { setName(conv.subject ?? ""); }, [conv.subject]);
|
||||
|
||||
const memberIds = useMemo(() => new Set(g.members.map((m) => m.userId)), [g.members]);
|
||||
const nameChanged = name.trim() && name.trim() !== (conv.subject ?? "").trim();
|
||||
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
|
||||
async function saveName() {
|
||||
if (!nameChanged || savingName) return;
|
||||
setSavingName(true);
|
||||
try { await g.rename(name.trim()); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setSavingName(false); }
|
||||
}
|
||||
async function add(userId: string) {
|
||||
setPendingId(userId);
|
||||
try { await g.addMember(userId); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setPendingId(null); }
|
||||
}
|
||||
async function remove(userId: string) {
|
||||
setPendingId(userId);
|
||||
try { await g.removeMember(userId); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setPendingId(null); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open onClose={onClose} title="Group settings" subtitle={conv.title} icon="settings"
|
||||
footer={<Btn variant="ghost" onClick={onClose}>Done</Btn>}
|
||||
>
|
||||
<Field label="Group name">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} disabled={!g.isAdmin}
|
||||
placeholder="Group name" style={{ ...inputStyle, opacity: g.isAdmin ? 1 : 0.6 }} />
|
||||
{g.isAdmin && <Btn onClick={() => void saveName()} disabled={!nameChanged || savingName}>{savingName ? "…" : "Save"}</Btn>}
|
||||
</div>
|
||||
{!g.isAdmin && <div style={{ color: "var(--muted)", fontSize: 12, marginTop: 4 }}>Only a group admin can rename the group.</div>}
|
||||
</Field>
|
||||
|
||||
<Field label={`Members (${g.members.length})`}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 200, overflowY: "auto" }}>
|
||||
{g.loading && g.members.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>Loading…</div>}
|
||||
{g.members.map((mem: UiMember) => (
|
||||
<div key={mem.userId} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10 }}>
|
||||
<Avatar initials={initialsOf(mem.displayName)} size={28} gradient={GROUP_GRAD} />
|
||||
<span style={{ flex: 1 }}>{mem.displayName}</span>
|
||||
{mem.role === "ADMIN" && <Pill tone="purple">admin</Pill>}
|
||||
{g.isAdmin && mem.role !== "ADMIN" && (
|
||||
<button onClick={() => void remove(mem.userId)} disabled={pendingId === mem.userId} title="Remove"
|
||||
style={{ ...actionBtnStyle, width: 28, height: 28 }}><Icon name="trash" size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{g.isAdmin && (
|
||||
<Field label="Add member">
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 180, overflowY: "auto" }}>
|
||||
{addable.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No one to add.</div>}
|
||||
{addable.map((p) => (
|
||||
<button key={p.id} onClick={() => void add(p.id)} disabled={pendingId === p.id}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: "transparent", border: "none", color: "var(--text)", textAlign: "left" }}>
|
||||
<Avatar initials={initialsOf(p.name)} size={28} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
||||
<span style={{ flex: 1 }}>{p.name}</span>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
// Topbar bell → offline-notification control. Click opens a small popover to enable/disable Web Push
|
||||
// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't
|
||||
// available (demo mode, or a browser without ServiceWorker/PushManager).
|
||||
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./ui";
|
||||
import { usePushNotifications } from "@/lib/push-notifications";
|
||||
|
||||
export function NotificationBell() {
|
||||
const push = usePushNotifications();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!push.supported) return null;
|
||||
|
||||
const denied = push.permission === "denied";
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
className="ic-btn"
|
||||
aria-label="Notifications"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
style={{ position: "relative" }}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="bell" size={18} />
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 9,
|
||||
right: 10,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 99,
|
||||
background: push.subscribed ? "var(--orange)" : "var(--border)",
|
||||
border: "2px solid var(--panel)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
|
||||
<div className="tm-menu-pop" role="menu" style={{ width: 260, padding: 14 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div>
|
||||
<p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}>
|
||||
{push.subscribed
|
||||
? "You'll get push notifications for new direct messages and mentions, even when this tab is closed."
|
||||
: "Get notified about direct messages and mentions when the CRM isn't open."}
|
||||
</p>
|
||||
|
||||
{denied ? (
|
||||
<p style={{ fontSize: 12, color: "var(--danger, #c0392b)", margin: 0 }}>
|
||||
Notifications are blocked in your browser settings. Allow them for this site, then try again.
|
||||
</p>
|
||||
) : push.subscribed ? (
|
||||
<button className="ds-btn v-ghost full" disabled={push.busy} onClick={() => push.disable()}>
|
||||
{push.busy ? "Turning off…" : "Turn off notifications"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="ds-btn v-primary full" disabled={push.busy} onClick={() => push.enable()}>
|
||||
{push.busy ? "Enabling…" : "Enable notifications"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
// App-wide in-app notifications. Uses the shared dashboard socket to watch activity across ALL of
|
||||
// the user's threads (via the adapter's subscribeActivity) and shows a clickable toast when a new
|
||||
// message arrives — unless you're already on the Messenger tab (you'd see it live there). Clicking
|
||||
// deep-links to the conversation. Renders nothing.
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import type { MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
import { useRealtime } from "@/lib/realtime";
|
||||
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||
import { useToast } from "./ui";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function NotificationCenter({ active, onNavigate }: { active: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||
const socket = useRealtime();
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const me = user?.id;
|
||||
|
||||
// Keep the current tab readable inside the (stable) subscription callback.
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
|
||||
const adapter = useMemo<MessagingAdapter | null>(
|
||||
() => (me && socket ? new CrmMessagingAdapter(sdk as unknown as DataDoor, me, socket) : null),
|
||||
[sdk, me, socket],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SHELL || !adapter?.subscribeActivity) return;
|
||||
return adapter.subscribeActivity(({ threadId, message }) => {
|
||||
if (message.actorId === me) return; // never notify me about my own message
|
||||
if (activeRef.current === "messenger") return; // already watching chat live
|
||||
toast.push({
|
||||
tone: "info",
|
||||
title: "New message",
|
||||
desc: message.text?.slice(0, 90) || "You have a new message",
|
||||
onClick: () => onNavigate("messenger", threadId),
|
||||
});
|
||||
});
|
||||
}, [adapter, me, toast, onNavigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Org Settings → Integrations. Today: SMS (Twilio) — a tenant
|
||||
// brings its OWN Twilio credentials, which be-crm seals in IIOS
|
||||
// (per-scope) and resolves at send time. The auth token is
|
||||
// write-only: sealed in IIOS, never read back, so status shows
|
||||
// only masked hints (from-number + SID last-4). Email (SMTP) is
|
||||
// the next provider on the same generic credential registry.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui";
|
||||
import { useSmsSettings } from "@/lib/sms-settings-api";
|
||||
import { useSmtpSettings } from "@/lib/smtp-settings-api";
|
||||
|
||||
const SID_RE = /^AC[0-9a-fA-F]{32}$/;
|
||||
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
export function Settings() {
|
||||
return (
|
||||
<div className="view">
|
||||
<PageHead
|
||||
eyebrow="Configuration"
|
||||
title="Org Settings"
|
||||
subtitle="Integrations and workspace configuration"
|
||||
icon="settings"
|
||||
/>
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">Integrations</h3>
|
||||
<div className="settings-grid">
|
||||
<TwilioCard />
|
||||
<SmtpCard />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TwilioCard() {
|
||||
const toast = useToast();
|
||||
const { status, loading, live, configure } = useSmsSettings();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [fromNumber, setFromNumber] = useState("");
|
||||
const [errors, setErrors] = useState<{ accountSid?: string; authToken?: string; fromNumber?: string }>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const showForm = editing || (!loading && !status.configured);
|
||||
|
||||
function validate(): boolean {
|
||||
const e: typeof errors = {};
|
||||
if (!SID_RE.test(accountSid.trim())) e.accountSid = "Must be a Twilio Account SID (AC + 32 hex chars).";
|
||||
if (!authToken.trim()) e.authToken = "Auth token is required.";
|
||||
if (!E164_RE.test(fromNumber.trim())) e.fromNumber = "Must be E.164, e.g. +15551234567.";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await configure({ accountSid: accountSid.trim(), authToken: authToken.trim(), fromNumber: fromNumber.trim() });
|
||||
toast.push({ tone: "success", title: "Twilio connected", desc: "Your SMS credentials are saved and encrypted." });
|
||||
setAccountSid(""); setAuthToken(""); setFromNumber(""); setErrors({}); setEditing(false);
|
||||
} catch (err) {
|
||||
toast.push({ tone: "error", title: "Couldn't save credentials", desc: (err as Error).message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-head">
|
||||
<span className="settings-card-ic" aria-hidden="true"><Icon name="send" size={20} /></span>
|
||||
<div className="settings-card-titles">
|
||||
<div className="settings-card-name">
|
||||
SMS <span className="settings-card-sub">· Twilio</span>
|
||||
</div>
|
||||
<div className="settings-card-desc">Send texts from your own Twilio number.</div>
|
||||
</div>
|
||||
{status.configured
|
||||
? <Pill tone="green">Connected</Pill>
|
||||
: <Pill tone="muted">Not connected</Pill>}
|
||||
</div>
|
||||
|
||||
{status.configured && !editing ? (
|
||||
<div className="settings-card-body">
|
||||
<dl className="settings-kv">
|
||||
<div><dt>From number</dt><dd>{status.fromNumber ?? "—"}</dd></div>
|
||||
<div><dt>Account SID</dt><dd>{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}</dd></div>
|
||||
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
|
||||
</dl>
|
||||
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showForm ? (
|
||||
<div className="settings-card-body">
|
||||
<Field label="Account SID" required error={errors.accountSid} hint="Twilio Console → Account Info.">
|
||||
<input className="ds-input" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Auth Token" required error={errors.authToken} hint="Encrypted on save and never shown again.">
|
||||
<input className="ds-input" type="password" value={authToken} onChange={(e) => setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="From number" required error={errors.fromNumber} hint="A Twilio number in E.164 format.">
|
||||
<input className="ds-input" value={fromNumber} onChange={(e) => setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" />
|
||||
</Field>
|
||||
<div className="settings-card-actions">
|
||||
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"}</Btn>
|
||||
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!live ? <div className="settings-card-note">Demo mode — credentials are stored locally and not sent to Twilio.</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmtpCard() {
|
||||
const toast = useToast();
|
||||
const { status, loading, live, configure } = useSmtpSettings();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("587");
|
||||
const [secure, setSecure] = useState(false);
|
||||
const [user, setUser] = useState("");
|
||||
const [pass, setPass] = useState("");
|
||||
const [fromEmail, setFromEmail] = useState("");
|
||||
const [fromName, setFromName] = useState("");
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const showForm = editing || (!loading && !status.configured);
|
||||
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {};
|
||||
if (!host.trim()) e.host = "SMTP host is required.";
|
||||
const p = Number(port);
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) e.port = "Port must be 1–65535.";
|
||||
if (!user.trim()) e.user = "Username is required.";
|
||||
if (!pass.trim()) e.pass = "Password is required.";
|
||||
if (!EMAIL_RE.test(fromEmail.trim())) e.fromEmail = "A valid from-address is required.";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await configure({ host: host.trim(), port: Number(port), secure, user: user.trim(), pass: pass.trim(), fromEmail: fromEmail.trim(), ...(fromName.trim() ? { fromName: fromName.trim() } : {}) });
|
||||
toast.push({ tone: "success", title: "SMTP connected", desc: "Outbound email now sends from your server." });
|
||||
setHost(""); setPort("587"); setSecure(false); setUser(""); setPass(""); setFromEmail(""); setFromName(""); setErrors({}); setEditing(false);
|
||||
} catch (err) {
|
||||
toast.push({ tone: "error", title: "Couldn't save SMTP settings", desc: (err as Error).message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-head">
|
||||
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
|
||||
<div className="settings-card-titles">
|
||||
<div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div>
|
||||
<div className="settings-card-desc">Send external email from your own mail server.</div>
|
||||
</div>
|
||||
{status.configured ? <Pill tone="green">Connected</Pill> : <Pill tone="muted">Not connected</Pill>}
|
||||
</div>
|
||||
|
||||
{status.configured && !editing ? (
|
||||
<div className="settings-card-body">
|
||||
<dl className="settings-kv">
|
||||
<div><dt>From</dt><dd>{status.fromName ? `${status.fromName} · ` : ""}{status.fromEmail ?? "—"}</dd></div>
|
||||
<div><dt>Server</dt><dd>{status.host ?? "—"}{status.port ? `:${status.port}` : ""}</dd></div>
|
||||
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
|
||||
</dl>
|
||||
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showForm ? (
|
||||
<div className="settings-card-body">
|
||||
<Field label="SMTP host" required error={errors.host} hint="e.g. smtp.sendgrid.net or your mail server.">
|
||||
<input className="ds-input" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Port" required error={errors.port} hint="587 (STARTTLS) or 465 (SSL).">
|
||||
<input className="ds-input" value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" autoComplete="off" />
|
||||
</Field>
|
||||
<label className="settings-check">
|
||||
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} /> Use SSL/TLS (port 465)
|
||||
</label>
|
||||
<Field label="Username" required error={errors.user} hint="Often your email or an API key.">
|
||||
<input className="ds-input" value={user} onChange={(e) => setUser(e.target.value)} placeholder="apikey / user@example.com" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Password" required error={errors.pass} hint="Encrypted on save and never shown again.">
|
||||
<input className="ds-input" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="••••••••••••" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="From address" required error={errors.fromEmail}>
|
||||
<input className="ds-input" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="From name" hint="Optional display name on outgoing mail.">
|
||||
<input className="ds-input" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="Acme Roofing" autoComplete="off" />
|
||||
</Field>
|
||||
<div className="settings-card-actions">
|
||||
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect SMTP"}</Btn>
|
||||
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!live ? <div className="settings-card-note">Demo mode — credentials are stored locally and no email is sent.</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Smart Gallery — the tenant's media library, powered by @photo-gallery/sdk embedded
|
||||
// inside the dashboard shell. The SDK owns the gallery experience (grid, lightbox,
|
||||
// photo + video editors, map, people, versions, comments, AI); this file owns the
|
||||
// LynkedUp chrome around it: page head, demo-mode banner, sizing, and failure
|
||||
// containment so a gallery fault can never take the dashboard down.
|
||||
//
|
||||
// Storage + identity come from `@/lib/gallery-api` (be-crm data door when the Shell is
|
||||
// configured, device-local otherwise). Theming comes from GALLERY_THEME_TOKENS, which
|
||||
// maps the SDK's tokens onto this dashboard's own CSS variables.
|
||||
// ============================================================
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Icon } from "./ui";
|
||||
import { useGalleryFeatures, useGalleryStorage } from "@/lib/gallery-api";
|
||||
|
||||
const SmartGalleryMount = dynamic(() => import("./smart-gallery-mount"), {
|
||||
ssr: false,
|
||||
loading: () => <GalleryPlaceholder label="Loading your library…" />,
|
||||
});
|
||||
|
||||
export function SmartGallery({ theme }: { theme: "dark" | "light" }) {
|
||||
const { live } = useGalleryStorage();
|
||||
// Whole-view gate. `media.view` with the same permissive fallback the feature toggles use, so a
|
||||
// superadmin/owner and the demo always pass, and a member is only blocked if they hold some Media
|
||||
// perms but not `media.view`. The sidebar keeps the row visible (see sidebar.tsx §4) — the real
|
||||
// gate is here.
|
||||
const { canView } = useGalleryFeatures();
|
||||
|
||||
return (
|
||||
<div className="view gal">
|
||||
{/* Slim header — the big PageHead ate vertical space the gallery needs. The CRM topbar already
|
||||
shows the "Smart Gallery" title; this compact row (~40px) just adds context and the icon. */}
|
||||
<div className="gal-head">
|
||||
<span className="gal-head-ic">
|
||||
<Icon name="gallery" size={16} />
|
||||
</span>
|
||||
<h1 className="gal-head-title">Smart Gallery</h1>
|
||||
<span className="gal-head-sub">Every photo and video for your jobs — searchable, editable and shareable.</span>
|
||||
</div>
|
||||
|
||||
{!live && (
|
||||
<div className="gal-banner">
|
||||
<Icon name="info" size={14} />
|
||||
<span>Demo mode — stored on this device only. It goes live once the Shell + be-crm are connected.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canView ? (
|
||||
<div className="gal-shell">
|
||||
<GalleryBoundary>
|
||||
<SmartGalleryMount theme={theme} />
|
||||
</GalleryBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gal-shell">
|
||||
<div className="gal-placeholder">
|
||||
<span className="gal-placeholder-ic">
|
||||
<Icon name="lock" size={28} />
|
||||
</span>
|
||||
<h3>You don't have access to the gallery</h3>
|
||||
<p>Ask a workspace admin to grant you the “View Smart Gallery” permission.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function GalleryPlaceholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="gal-placeholder">
|
||||
<span className="gal-placeholder-ic">
|
||||
<Icon name="gallery" size={30} />
|
||||
</span>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery is a large third-party surface with canvas, workers and optional ML models. A render
|
||||
* fault inside it must degrade to a message rather than blanking the whole dashboard, so it gets its
|
||||
* own error boundary. (Error boundaries still require a class component in React 19.)
|
||||
*/
|
||||
class GalleryBoundary extends Component<{ children: ReactNode }, BoundaryState> {
|
||||
state: BoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): BoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("[smart-gallery] render failed", error, info.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div className="gal-placeholder gal-placeholder-error">
|
||||
<span className="gal-placeholder-ic">
|
||||
<Icon name="alert" size={30} />
|
||||
</span>
|
||||
<h3>The gallery could not be displayed</h3>
|
||||
<p>{error.message || "An unexpected error occurred."}</p>
|
||||
<button className="ds-btn v-outline s-sm" onClick={() => this.setState({ error: null })}>
|
||||
<Icon name="refresh" size={14} /> Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// The CRM's InboxAdapter — the SDK <Inbox> rendered over the be-crm data door
|
||||
// (crm.inbox.* + crm.mail.*). Folds mail threads into the unified inbox exactly as the old
|
||||
// inbox-api did; the CRM keeps auth/tenancy server-side.
|
||||
|
||||
import type {
|
||||
InboxAdapter,
|
||||
InboxItem,
|
||||
InboxState,
|
||||
MailAttachment,
|
||||
MailMessage,
|
||||
MailPerson,
|
||||
} from "@insignia/iios-messaging-ui";
|
||||
import type { DataDoor } from "./crm-messaging-adapter";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
|
||||
|
||||
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
|
||||
const EXT_MIME: Record<string, string> = {
|
||||
md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html", txt: "text/plain", csv: "text/csv",
|
||||
};
|
||||
function mimeForFile(file: File): string {
|
||||
if (file.type) return file.type;
|
||||
const ext = file.name.toLowerCase().split(".").pop() ?? "";
|
||||
return EXT_MIME[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
interface InboxItemDTO {
|
||||
id: string; kind: string; state: InboxState; title: string; summary?: string; priority: string; threadId?: string; createdAt: string;
|
||||
}
|
||||
interface MailThreadDTO { threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string }
|
||||
interface MailMessageDTO {
|
||||
interactionId: string; actorId: string | null; kind: string; occurredAt: string;
|
||||
html: string | null; text: string | null;
|
||||
attachment: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null;
|
||||
}
|
||||
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
||||
|
||||
const escapeHtml = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
export class CrmInboxAdapter implements InboxAdapter {
|
||||
constructor(private readonly sdk: DataDoor) {}
|
||||
|
||||
async listInbox(state?: InboxState): Promise<InboxItem[]> {
|
||||
const showMail = !state || state === "OPEN";
|
||||
const [items, mail] = await Promise.all([
|
||||
this.sdk.query<InboxItemDTO[]>("crm.inbox.list", state ? { state } : {}),
|
||||
showMail ? this.sdk.query<MailThreadDTO[]>("crm.mail.list", {}) : Promise.resolve([] as MailThreadDTO[]),
|
||||
]);
|
||||
const mailItems: InboxItem[] = mail.map((t) => ({
|
||||
id: `mail:${t.threadId}`,
|
||||
kind: "MAIL",
|
||||
state: "OPEN",
|
||||
title: t.subject || "(no subject)",
|
||||
...(t.lastMessage ? { summary: t.lastMessage } : {}),
|
||||
priority: t.unread > 0 ? "HIGH" : "LOW",
|
||||
threadId: t.threadId,
|
||||
createdAt: t.lastAt ?? "",
|
||||
}));
|
||||
return [...mailItems, ...items].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
||||
}
|
||||
|
||||
async transition(id: string, state: InboxState): Promise<void> {
|
||||
await this.sdk.command("crm.inbox.transition", { id, state });
|
||||
}
|
||||
|
||||
async mailHistory(threadId: string): Promise<MailMessage[]> {
|
||||
const rows = await this.sdk.query<MailMessageDTO[]>("crm.mail.history", { threadId });
|
||||
return rows.map((m) => ({
|
||||
id: m.interactionId,
|
||||
actorId: m.actorId,
|
||||
kind: m.kind,
|
||||
at: m.occurredAt,
|
||||
html: m.html,
|
||||
text: m.text,
|
||||
attachment: m.attachment,
|
||||
}));
|
||||
}
|
||||
|
||||
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
|
||||
await this.sdk.command("crm.mail.reply", {
|
||||
threadId,
|
||||
content,
|
||||
...(attachment ? { attachment: { filename: attachment.filename ?? "attachment", contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadAttachment(file: File): Promise<MailAttachment> {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
|
||||
const mime = mimeForFile(file);
|
||||
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
|
||||
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: MailAttachment): Promise<string> {
|
||||
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", {
|
||||
contentRef: attachment.contentRef,
|
||||
...(attachment.mimeType ? { mime: attachment.mimeType } : {}),
|
||||
});
|
||||
return url;
|
||||
}
|
||||
|
||||
async directory(): Promise<MailPerson[]> {
|
||||
const rows = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
||||
return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
|
||||
}
|
||||
|
||||
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||
await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
|
||||
}
|
||||
|
||||
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||
await this.sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentsVar(attachments?: MailAttachment[]): { attachments?: Array<{ filename: string; contentRef: string; mimeType: string; sizeBytes: number }> } {
|
||||
if (!attachments || attachments.length === 0) return {};
|
||||
return { attachments: attachments.map((a) => ({ filename: a.filename ?? "attachment", contentRef: a.contentRef, mimeType: a.mimeType, sizeBytes: a.sizeBytes })) };
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// The CRM's implementation of the SDK's MessagingAdapter. HYBRID transport:
|
||||
// • BFF (appshell crm.messenger.*) for the conversation list, thread creation, and directory
|
||||
// — these need server-side tenancy/auth.
|
||||
// • IIOS MessageSocket (delegated token from crm.messenger.realtime) for everything live:
|
||||
// history+join, send, typing, read receipts, reactions.
|
||||
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
|
||||
|
||||
import type {
|
||||
Attachment,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
CreateChannelInput,
|
||||
Membership,
|
||||
Message,
|
||||
MessageEvent,
|
||||
MessagingAdapter,
|
||||
Person,
|
||||
Reaction,
|
||||
SendOpts,
|
||||
Unsubscribe,
|
||||
} from "@insignia/iios-messaging-ui";
|
||||
import type { MessageSocket, Message as KernelMessage } from "@insignia/iios-kernel-client";
|
||||
|
||||
/** The imperative appshell data door (useAppShell().sdk). Typed structurally, not to its class. */
|
||||
export interface DataDoor {
|
||||
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||
}
|
||||
|
||||
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
||||
interface ConversationDTO {
|
||||
threadId: string; subject: string | null; membership: Membership | null;
|
||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||
}
|
||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null; attachment?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null }
|
||||
|
||||
const POLL_MS = 4000;
|
||||
const REACTION = "reaction";
|
||||
|
||||
interface Poll { seen: Set<string>; primed: boolean; timer: ReturnType<typeof setInterval> | null }
|
||||
|
||||
export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
private names: Map<string, string> | null = null;
|
||||
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
||||
private readonly polls = new Map<string, Poll>();
|
||||
private readonly joined = new Set<string>();
|
||||
/** Cross-thread activity listeners (live unread + in-app notifications). */
|
||||
private readonly activity = new Set<(e: { threadId: string; message: Message }) => void>();
|
||||
/** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */
|
||||
private readonly reactions = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
/** Only present with a socket — the UI hides the reaction affordance without it. */
|
||||
react?: (threadId: string, messageId: string, emoji: string) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly sdk: DataDoor,
|
||||
private readonly me: string,
|
||||
private readonly socket?: MessageSocket,
|
||||
) {
|
||||
if (socket) {
|
||||
socket.on("message", (m) => {
|
||||
this.ingestReactions(m);
|
||||
void this.toKernelMessage(m).then((message) => {
|
||||
this.emit(m.threadId, { kind: "message", message });
|
||||
for (const cb of this.activity) cb({ threadId: m.threadId, message });
|
||||
});
|
||||
});
|
||||
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
|
||||
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
|
||||
socket.on("receipt", (e) => this.broadcast({ kind: "receipt", messageId: e.interactionId, actorId: e.actorId }));
|
||||
socket.on("annotation", (e) => {
|
||||
if (e.type !== REACTION) return;
|
||||
this.setReactionUsers(e.interactionId, e.value, e.users);
|
||||
this.emit(e.threadId, { kind: "reaction", messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
|
||||
});
|
||||
this.react = async (threadId, messageId, emoji) => {
|
||||
await socket.react(threadId, messageId, emoji);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
currentActorId(): string {
|
||||
return this.me;
|
||||
}
|
||||
|
||||
/** Report the foregrounded thread to IIOS presence (suppresses push for what you're viewing). */
|
||||
setFocus(threadId: string | null): void {
|
||||
this.socket?.focus(threadId);
|
||||
}
|
||||
|
||||
/** Fire `cb` for every incoming message across ALL the caller's threads (live unread + toasts). */
|
||||
subscribeActivity(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe {
|
||||
this.activity.add(cb);
|
||||
void this.joinAllThreads();
|
||||
return () => {
|
||||
this.activity.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** Join every thread the user belongs to so their messages arrive over the socket, not just the
|
||||
* open one. Idempotent (the `joined` set guards re-joins). */
|
||||
private async joinAllThreads(): Promise<void> {
|
||||
if (!this.socket) return;
|
||||
try {
|
||||
const convs = await this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {});
|
||||
for (const c of convs) {
|
||||
if (!this.joined.has(c.threadId)) {
|
||||
this.joined.add(c.threadId);
|
||||
void this.socket.openThread(c.threadId).catch(() => this.joined.delete(c.threadId));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort — activity just won't cover un-joined threads */
|
||||
}
|
||||
}
|
||||
|
||||
async listConversations(): Promise<Conversation[]> {
|
||||
const [convs, names] = await Promise.all([
|
||||
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
|
||||
this.directoryMap(),
|
||||
]);
|
||||
return convs.map((c) => this.toConversation(c, names));
|
||||
}
|
||||
|
||||
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
|
||||
participantIds: p.participantIds,
|
||||
...(p.membership ? { membership: p.membership } : {}),
|
||||
...(p.subject ? { subject: p.subject } : {}),
|
||||
});
|
||||
return { threadId: res.threadId };
|
||||
}
|
||||
|
||||
async history(threadId: string): Promise<Message[]> {
|
||||
if (this.socket) {
|
||||
const res = await this.socket.openThread(threadId); // joins so live events flow
|
||||
this.joined.add(threadId);
|
||||
return Promise.all(
|
||||
res.history.map((m) => {
|
||||
this.ingestReactions(m);
|
||||
return this.toKernelMessage(m);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||
return Promise.all(msgs.map((m) => this.toDtoMessage(m)));
|
||||
}
|
||||
|
||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||
const att = opts?.attachment;
|
||||
if (this.socket) {
|
||||
const sendOpts = {
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
||||
...(att?.contentRef ? { attachment: { contentRef: att.contentRef, mimeType: att.mime, sizeBytes: att.sizeBytes ?? 0 } } : {}),
|
||||
};
|
||||
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||
const msg = this.fromKernel(m);
|
||||
// Reuse the staged attachment (already carries a display URL from upload) for instant render.
|
||||
return att ? { ...msg, attachment: att } : msg;
|
||||
}
|
||||
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
|
||||
const msg = this.fromDto(m);
|
||||
this.polls.get(threadId)?.seen.add(msg.id);
|
||||
return att ? { ...msg, attachment: att } : msg;
|
||||
}
|
||||
|
||||
async upload(file: File): Promise<Attachment> {
|
||||
const mime = file.type || "application/octet-stream";
|
||||
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
|
||||
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||
const url = await this.downloadUrl(objectKey, mime);
|
||||
return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size };
|
||||
}
|
||||
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
|
||||
this.listeners.get(threadId)!.add(cb);
|
||||
|
||||
if (this.socket) {
|
||||
if (!this.joined.has(threadId)) {
|
||||
this.joined.add(threadId);
|
||||
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
|
||||
}
|
||||
} else {
|
||||
this.startPoll(threadId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const set = this.listeners.get(threadId);
|
||||
set?.delete(cb);
|
||||
if (set && set.size === 0) {
|
||||
this.listeners.delete(threadId);
|
||||
const poll = this.polls.get(threadId);
|
||||
if (poll?.timer) clearInterval(poll.timer);
|
||||
this.polls.delete(threadId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
sendTyping(threadId: string): void {
|
||||
this.socket?.typing(threadId);
|
||||
}
|
||||
|
||||
async markRead(threadId: string, messageId: string): Promise<void> {
|
||||
if (this.socket) await this.socket.markRead(threadId, messageId);
|
||||
}
|
||||
|
||||
// ── channels + members (BFF, except join which is a governed socket self-join) ──
|
||||
async browseChannels(): Promise<ChannelSummary[]> {
|
||||
const rows = await this.sdk.query<Array<{ threadId: string; name: string; topic: string | null; visibility: string; memberCount: number; joined: boolean }>>(
|
||||
"crm.messenger.channel.browse",
|
||||
{},
|
||||
);
|
||||
return rows.map((c) => ({
|
||||
threadId: c.threadId,
|
||||
name: c.name,
|
||||
topic: c.topic,
|
||||
visibility: (c.visibility === "private" ? "private" : "public") as ChannelVisibility,
|
||||
memberCount: c.memberCount,
|
||||
joined: c.joined,
|
||||
}));
|
||||
}
|
||||
|
||||
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
|
||||
return this.sdk.command<{ threadId: string }>("crm.messenger.channel.create", {
|
||||
name: input.name,
|
||||
...(input.topic ? { topic: input.topic } : {}),
|
||||
visibility: input.visibility,
|
||||
});
|
||||
}
|
||||
|
||||
async joinChannel(threadId: string): Promise<void> {
|
||||
// Governed public self-join over the socket (the BFF has no join verb; OPA enforces it).
|
||||
if (!this.socket) throw new Error("joining a channel needs a live connection");
|
||||
await this.socket.openThread(threadId);
|
||||
this.joined.add(threadId);
|
||||
}
|
||||
|
||||
async leaveChannel(threadId: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.channel.leave", { threadId });
|
||||
}
|
||||
|
||||
async addMember(threadId: string, userId: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.participant.add", { threadId, userId });
|
||||
}
|
||||
|
||||
async removeMember(threadId: string, userId: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.participant.remove", { threadId, userId });
|
||||
}
|
||||
|
||||
async renameConversation(threadId: string, subject: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.group.rename", { threadId, subject });
|
||||
}
|
||||
|
||||
async listMembers(threadId: string): Promise<Person[]> {
|
||||
const rows = await this.sdk.query<Array<{ userId: string; displayName: string; role: string }>>("crm.messenger.members", { threadId });
|
||||
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: r.role === "CUSTOMER" ? "customer" : "staff" }));
|
||||
}
|
||||
|
||||
// ── polling fallback (no socket) ───────────────────────────────
|
||||
private startPoll(threadId: string): void {
|
||||
if (this.polls.has(threadId)) return;
|
||||
const poll: Poll = { seen: new Set(), primed: false, timer: null };
|
||||
this.polls.set(threadId, poll);
|
||||
const tick = async (): Promise<void> => {
|
||||
if (!this.polls.has(threadId)) return;
|
||||
try {
|
||||
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||
for (const m of msgs) {
|
||||
if (poll.seen.has(m.interactionId)) continue;
|
||||
poll.seen.add(m.interactionId);
|
||||
if (poll.primed) this.emit(threadId, { kind: "message", message: this.fromDto(m) });
|
||||
}
|
||||
poll.primed = true;
|
||||
} catch {
|
||||
/* transient — retry next tick */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
poll.timer = setInterval(tick, POLL_MS);
|
||||
}
|
||||
|
||||
/** The org directory — people you can start a DM/group with. Drives the "New message" picker. */
|
||||
async directory(): Promise<Person[]> {
|
||||
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
||||
return dir.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
|
||||
}
|
||||
|
||||
// ── mapping ────────────────────────────────────────────────────
|
||||
private async directoryMap(): Promise<Map<string, string>> {
|
||||
if (!this.names) {
|
||||
this.names = new Map((await this.directory()).map((p) => [p.id, p.name]));
|
||||
}
|
||||
return this.names;
|
||||
}
|
||||
|
||||
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
|
||||
const others = c.participants.filter((p) => p !== this.me);
|
||||
const title = c.subject?.trim() || others.map((id) => names.get(id) ?? id).join(", ") || "Conversation";
|
||||
return {
|
||||
threadId: c.threadId,
|
||||
title,
|
||||
subject: c.subject,
|
||||
membership: c.membership,
|
||||
participants: c.participants,
|
||||
unread: c.unread,
|
||||
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
|
||||
...(c.lastAt ? { lastAt: c.lastAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Kernel Message (socket) → SDK Message. actorId = senderId (userId space), matching currentActorId. */
|
||||
private fromKernel(m: KernelMessage): Message {
|
||||
return {
|
||||
id: m.id,
|
||||
actorId: m.senderId ?? null,
|
||||
text: m.content ?? "",
|
||||
at: m.createdAt,
|
||||
parentInteractionId: m.parentInteractionId ?? null,
|
||||
reactions: this.reactionsOf(m.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** BFF DTO (poll fallback) → SDK Message. Note: actorId is IIOS actor-id space here. */
|
||||
private fromDto(m: MessageDTO): Message {
|
||||
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
|
||||
}
|
||||
|
||||
// ── attachments ────────────────────────────────────────────────
|
||||
/** A short-lived signed URL to display/download a stored object. */
|
||||
private async downloadUrl(contentRef: string, mime?: string): Promise<string> {
|
||||
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) });
|
||||
return url;
|
||||
}
|
||||
|
||||
private async resolveAttachment(a: { contentRef: string; mimeType: string; sizeBytes: number; filename?: string | null } | null | undefined): Promise<Attachment | undefined> {
|
||||
if (!a?.contentRef) return undefined;
|
||||
const url = await this.downloadUrl(a.contentRef, a.mimeType);
|
||||
return { url, mime: a.mimeType, name: a.filename ?? "attachment", contentRef: a.contentRef, sizeBytes: a.sizeBytes };
|
||||
}
|
||||
|
||||
private async toKernelMessage(m: KernelMessage): Promise<Message> {
|
||||
const base = this.fromKernel(m);
|
||||
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||
return att ? { ...base, attachment: att } : base;
|
||||
}
|
||||
|
||||
private async toDtoMessage(m: MessageDTO): Promise<Message> {
|
||||
const base = this.fromDto(m);
|
||||
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||
return att ? { ...base, attachment: att } : base;
|
||||
}
|
||||
|
||||
// ── reaction state ─────────────────────────────────────────────
|
||||
private ingestReactions(m: KernelMessage): void {
|
||||
for (const a of m.annotations ?? []) {
|
||||
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
|
||||
}
|
||||
}
|
||||
|
||||
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
|
||||
let byEmoji = this.reactions.get(messageId);
|
||||
if (!byEmoji) {
|
||||
byEmoji = new Map();
|
||||
this.reactions.set(messageId, byEmoji);
|
||||
}
|
||||
if (users.length === 0) byEmoji.delete(emoji);
|
||||
else byEmoji.set(emoji, new Set(users));
|
||||
}
|
||||
|
||||
private reactionsOf(messageId: string): Reaction[] {
|
||||
const byEmoji = this.reactions.get(messageId);
|
||||
if (!byEmoji) return [];
|
||||
const out: Reaction[] = [];
|
||||
for (const [emoji, users] of byEmoji) {
|
||||
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── event fan-out ──────────────────────────────────────────────
|
||||
private emit(threadId: string, e: MessageEvent): void {
|
||||
this.listeners.get(threadId)?.forEach((cb) => cb(e));
|
||||
}
|
||||
|
||||
private broadcast(e: MessageEvent): void {
|
||||
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The CRM's AIProvider for the Smart Gallery.
|
||||
*
|
||||
* PROVENANCE: a port of the SDK demo's `createDemoAIProvider()` plus its
|
||||
* clip/face/ocr/tensorflow/runpod-yolo providers and `imageEncode.ts` helpers
|
||||
* (advance-photo-gallery-web-sdk/apps/web/src/lib/ai/*), collapsed into one
|
||||
* module and retargeted from `/api/ai/*` to `/api/gallery/ai/*`.
|
||||
*
|
||||
* Capability split:
|
||||
* - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key), or the
|
||||
* RunPod YOLO classifier via /api/gallery/ai/classify when
|
||||
* NEXT_PUBLIC_APG_RUNPOD_DETECT=true (COCO-SSD is the automatic fallback)
|
||||
* - face detection + recognition: face-api.js in-browser → clustered into People
|
||||
* - OCR: tesseract.js in-browser → searchable text + the Documents album
|
||||
* - semantic search: CLIP via transformers.js in-browser
|
||||
* - background removal: @imgly in-browser WASM, or the RunPod U²-Net endpoint
|
||||
* when NEXT_PUBLIC_APG_RUNPOD_BG=true (in-browser is the fallback)
|
||||
* - other generative edits / transcription / denoise / tilt: proxied through the
|
||||
* server routes so the RunPod key never reaches the browser
|
||||
*
|
||||
* EVERY heavy model is behind `await import(...)` so none of it lands in the
|
||||
* initial bundle, and every capability degrades to []/''/null with a
|
||||
* console.warn rather than throwing — a failed model must never break the
|
||||
* gallery UI.
|
||||
*
|
||||
* The in-browser models fetch weights from public CDNs (jsdelivr, huggingface,
|
||||
* storage.googleapis.com, staticimgly.com). See docs/SMART_GALLERY.md for the
|
||||
* list that would need CSP allow-listing.
|
||||
*/
|
||||
|
||||
import type { AIProvider, GenerativeEditOp, MediaItem } from "@photo-gallery/sdk";
|
||||
|
||||
// Derived from the provider interface so we import only the three public types.
|
||||
type DetectedObject = Awaited<ReturnType<NonNullable<AIProvider["detectObjects"]>>>[number];
|
||||
type DetectedFace = Awaited<ReturnType<NonNullable<AIProvider["detectFaces"]>>>[number];
|
||||
type ImageSource = ImageBitmap | HTMLImageElement;
|
||||
|
||||
const API = "/api/gallery/ai";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// imageEncode helpers (ported from apps/web/src/lib/ai/imageEncode.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EncodedImage {
|
||||
/** base64 JPEG (no data: prefix). */
|
||||
data: string;
|
||||
mimeType: string;
|
||||
/** Actual pixel dims of the encoded image (after downscale). */
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Draw an image to a downscaled canvas and return base64 JPEG + its dims. */
|
||||
export function imageToBase64(image: ImageSource, maxDim: number): EncodedImage {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported.");
|
||||
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
|
||||
return { data: dataUrl.split(",")[1] ?? "", mimeType: "image/jpeg", width: cw, height: ch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterize a mask (ImageData, white = region to regenerate) to a PNG base64
|
||||
* scaled to targetW×targetH so it matches the encoded image exactly — SD 3.5
|
||||
* requires image and mask to be identical pixel sizes.
|
||||
*/
|
||||
export function maskToBase64(mask: ImageData, targetW: number, targetH: number): string {
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = mask.width;
|
||||
tmp.height = mask.height;
|
||||
const tctx = tmp.getContext("2d");
|
||||
if (!tctx) throw new Error("Canvas not supported.");
|
||||
tctx.putImageData(mask, 0, 0);
|
||||
|
||||
const out = document.createElement("canvas");
|
||||
out.width = targetW;
|
||||
out.height = targetH;
|
||||
const octx = out.getContext("2d");
|
||||
if (!octx) throw new Error("Canvas not supported.");
|
||||
// Nearest-neighbour, not bilinear — keep the mask strictly binary so SD gets
|
||||
// crisp white(regenerate)/black(keep) edges instead of an anti-aliased grey halo.
|
||||
octx.imageSmoothingEnabled = false;
|
||||
octx.drawImage(tmp, 0, 0, targetW, targetH);
|
||||
return out.toDataURL("image/png").split(",")[1] ?? "";
|
||||
}
|
||||
|
||||
export function base64ToBlob(base64: string, mime: string): Blob {
|
||||
const bin = atob(base64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad an image with a neutral border for outpaint and return {imageBase64, maskBase64}
|
||||
* as base64 PNG — the border is WHITE in the mask (regenerate), the original image
|
||||
* area BLACK (keep). Capped at 1280px on the long side.
|
||||
*/
|
||||
export function padForOutpaint(
|
||||
image: ImageSource,
|
||||
factor: number,
|
||||
): { imageBase64: string; maskBase64: string } {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const f = Math.max(1.1, Math.min(2, factor));
|
||||
const maxDim = 1280;
|
||||
let pw = Math.round(w * f);
|
||||
let ph = Math.round(h * f);
|
||||
const scale = Math.min(1, maxDim / Math.max(pw, ph));
|
||||
pw = Math.max(16, Math.round(pw * scale));
|
||||
ph = Math.max(16, Math.round(ph * scale));
|
||||
const iw = Math.max(1, Math.round(w * scale));
|
||||
const ih = Math.max(1, Math.round(h * scale));
|
||||
const ox = Math.floor((pw - iw) / 2);
|
||||
const oy = Math.floor((ph - ih) / 2);
|
||||
|
||||
const imgCanvas = document.createElement("canvas");
|
||||
imgCanvas.width = pw;
|
||||
imgCanvas.height = ph;
|
||||
const ictx = imgCanvas.getContext("2d");
|
||||
if (!ictx) throw new Error("Canvas not supported.");
|
||||
// Fill the new border with a blurred, stretched copy of the photo so the model
|
||||
// has real color/context to continue from — flat gray gives it nothing.
|
||||
ictx.filter = "blur(28px)";
|
||||
ictx.drawImage(image as CanvasImageSource, 0, 0, pw, ph);
|
||||
ictx.filter = "none";
|
||||
ictx.drawImage(image as CanvasImageSource, ox, oy, iw, ih);
|
||||
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = pw;
|
||||
maskCanvas.height = ph;
|
||||
const mctx = maskCanvas.getContext("2d");
|
||||
if (!mctx) throw new Error("Canvas not supported.");
|
||||
mctx.fillStyle = "#ffffff";
|
||||
mctx.fillRect(0, 0, pw, ph);
|
||||
mctx.fillStyle = "#000000";
|
||||
mctx.fillRect(ox, oy, iw, ih);
|
||||
|
||||
return {
|
||||
imageBase64: imgCanvas.toDataURL("image/png").split(",")[1] ?? "",
|
||||
maskBase64: maskCanvas.toDataURL("image/png").split(",")[1] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Draw an image to a canvas (downscaled) and return a JPEG Blob. */
|
||||
function canvasBlob(image: ImageSource, maxDim: number): Promise<Blob> {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return Promise.reject(new Error("Canvas not supported."));
|
||||
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
|
||||
return new Promise((resolve, reject) =>
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92),
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return n < 0 ? 0 : n > 1 ? 1 : n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object detection — TensorFlow.js COCO-SSD, in-browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CocoPrediction {
|
||||
bbox: [number, number, number, number];
|
||||
class: string;
|
||||
score: number;
|
||||
}
|
||||
interface CocoModel {
|
||||
detect(
|
||||
img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement,
|
||||
maxNumBoxes?: number,
|
||||
minScore?: number,
|
||||
): Promise<CocoPrediction[]>;
|
||||
}
|
||||
|
||||
let cocoPromise: Promise<CocoModel | null> | null = null;
|
||||
|
||||
/** Load tfjs + COCO-SSD exactly once; resolves to null if anything fails. */
|
||||
function ensureCoco(): Promise<CocoModel | null> {
|
||||
cocoPromise ??= (async () => {
|
||||
try {
|
||||
const tf = await import("@tensorflow/tfjs");
|
||||
try {
|
||||
await tf.setBackend("webgl");
|
||||
} catch {
|
||||
// Fall back to the default backend if WebGL is unavailable.
|
||||
}
|
||||
await tf.ready();
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
return (await cocoSsd.load({ base: "lite_mobilenet_v2" })) as unknown as CocoModel;
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] COCO-SSD load failed; object detection disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return cocoPromise;
|
||||
}
|
||||
|
||||
async function detectObjectsInBrowser(
|
||||
item: MediaItem,
|
||||
image: ImageSource,
|
||||
): Promise<DetectedObject[]> {
|
||||
const model = await ensureCoco();
|
||||
if (!model) return [];
|
||||
try {
|
||||
const el = image as HTMLImageElement;
|
||||
const w = el.naturalWidth || el.width || item.width || 1;
|
||||
const h = el.naturalHeight || el.height || item.height || 1;
|
||||
const predictions = await model.detect(el, 20, 0.4);
|
||||
return predictions.map((p) => ({
|
||||
label: p.class,
|
||||
confidence: p.score,
|
||||
box: { x: p.bbox[0] / w, y: p.bbox[1] / h, width: p.bbox[2] / w, height: p.bbox[3] / h },
|
||||
})) as DetectedObject[];
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] object detection failed.", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side YOLO detection via /api/gallery/ai/classify, with the in-browser
|
||||
* COCO-SSD as the automatic fallback so detection never hard-fails.
|
||||
*/
|
||||
async function detectObjectsViaRunpod(
|
||||
item: MediaItem,
|
||||
image: ImageSource,
|
||||
): Promise<DetectedObject[]> {
|
||||
try {
|
||||
const { data, mimeType, width, height } = imageToBase64(image, 1280);
|
||||
const res = await fetch(`${API}/classify`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ imageBase64: data, mimeType, width, height }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`classify failed (${res.status})`);
|
||||
const { objects } = (await res.json()) as { objects?: DetectedObject[] };
|
||||
if (Array.isArray(objects)) return objects;
|
||||
throw new Error("classify returned no objects");
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] RunPod detection failed; falling back to COCO-SSD.", err);
|
||||
return detectObjectsInBrowser(item, image);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Faces — @vladmandic/face-api, in-browser (128-D descriptors → People)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FACE_MODEL_URL = "https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model";
|
||||
|
||||
type FaceApi = typeof import("@vladmandic/face-api");
|
||||
|
||||
let facePromise: Promise<FaceApi | null> | null = null;
|
||||
|
||||
function ensureFaceModels(): Promise<FaceApi | null> {
|
||||
facePromise ??= (async () => {
|
||||
try {
|
||||
const faceapi = await import("@vladmandic/face-api");
|
||||
// The bundled tf re-export is typed narrowly; backend control lives on the
|
||||
// runtime object. Prefer WebGL (no eval; CSP-friendly), fall back gracefully.
|
||||
const tf = faceapi.tf as unknown as {
|
||||
setBackend: (b: string) => Promise<boolean>;
|
||||
ready: () => Promise<void>;
|
||||
};
|
||||
try {
|
||||
await tf.setBackend("webgl");
|
||||
} catch {
|
||||
/* keep default backend */
|
||||
}
|
||||
await tf.ready();
|
||||
await Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_URL),
|
||||
faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_URL),
|
||||
]);
|
||||
return faceapi;
|
||||
} catch (err) {
|
||||
// Degrade gracefully — People simply stays empty if models can't load.
|
||||
console.warn("[gallery-ai] face model load failed; face clustering disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return facePromise;
|
||||
}
|
||||
|
||||
let faceWarned = false;
|
||||
|
||||
async function detectFaces(item: MediaItem, image: ImageSource): Promise<DetectedFace[]> {
|
||||
const faceapi = await ensureFaceModels();
|
||||
if (!faceapi) return [];
|
||||
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width || 1;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height || 1;
|
||||
|
||||
type TNetInput = Parameters<FaceApi["detectAllFaces"]>[0];
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await faceapi
|
||||
.detectAllFaces(
|
||||
image as unknown as TNetInput,
|
||||
new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 }),
|
||||
)
|
||||
.withFaceLandmarks()
|
||||
.withFaceDescriptors();
|
||||
} catch (err) {
|
||||
if (!faceWarned) {
|
||||
faceWarned = true;
|
||||
console.warn("[gallery-ai] face detection failed on", item.name, err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return results.map((r) => {
|
||||
const b = r.detection.box;
|
||||
return {
|
||||
confidence: r.detection.score,
|
||||
box: {
|
||||
x: clamp01(b.x / w),
|
||||
y: clamp01(b.y / h),
|
||||
width: clamp01(b.width / w),
|
||||
height: clamp01(b.height / h),
|
||||
},
|
||||
embedding: Array.from(r.descriptor),
|
||||
};
|
||||
}) as DetectedFace[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OCR — tesseract.js, in-browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Must equal the EXACT tesseract.js version in package.json (pinned, no caret)
|
||||
// so the worker CDN URL can never drift from the installed main-thread code.
|
||||
const TESSERACT_VERSION = "5.1.1";
|
||||
const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${TESSERACT_VERSION}/dist/worker.min.js`;
|
||||
const CORE_PATH = "https://cdn.jsdelivr.net/npm/tesseract.js-core@5";
|
||||
// jsDelivr's GitHub mirror of naptha/tessdata (same files as projectnaptha.com),
|
||||
// so every asset comes from ONE host that a CSP can allow-list.
|
||||
const LANG_PATH = "https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0";
|
||||
|
||||
interface OcrWord {
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
interface OcrData {
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
words?: OcrWord[];
|
||||
blocks?: Array<{ paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }> }> | null;
|
||||
}
|
||||
|
||||
type TesseractWorker = import("tesseract.js").Worker;
|
||||
|
||||
let ocrWorkerPromise: Promise<TesseractWorker | null> | null = null;
|
||||
|
||||
function ensureOcrWorker(): Promise<TesseractWorker | null> {
|
||||
ocrWorkerPromise ??= (async () => {
|
||||
try {
|
||||
const { createWorker } = await import("tesseract.js");
|
||||
// v5: createWorker(langs, oem, options) already loads + initializes the
|
||||
// language internally — do NOT call the removed v4 worker.load().
|
||||
return await createWorker("eng", 1, {
|
||||
workerPath: WORKER_PATH,
|
||||
corePath: CORE_PATH,
|
||||
langPath: LANG_PATH,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] tesseract worker init failed; OCR disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return ocrWorkerPromise;
|
||||
}
|
||||
|
||||
const WORD_CONFIDENCE = 70; // a word tesseract is actually sure about
|
||||
const MIN_WORDS = 4; // need several confident words to call it a document
|
||||
const MIN_CHARS = 10;
|
||||
|
||||
function collectWords(data: OcrData): OcrWord[] {
|
||||
if (Array.isArray(data.words) && data.words.length) return data.words;
|
||||
const out: OcrWord[] = [];
|
||||
for (const b of data.blocks ?? [])
|
||||
for (const p of b.paragraphs ?? [])
|
||||
for (const l of p.lines ?? []) for (const w of l.words ?? []) out.push(w);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return real text or '' (not a document). tesseract hallucinates low-confidence
|
||||
* gibberish for photos with no text, so we keep only high-confidence, word-shaped
|
||||
* tokens and require several of them.
|
||||
*/
|
||||
function meaningfulText(data: OcrData): string {
|
||||
const words = collectWords(data);
|
||||
if (words.length > 0) {
|
||||
const good = words.filter(
|
||||
(w) => (w.confidence ?? 0) >= WORD_CONFIDENCE && /[A-Za-z0-9]{2,}/.test(w.text ?? ""),
|
||||
);
|
||||
const text = good
|
||||
.map((w) => (w.text ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
return good.length >= MIN_WORDS && text.length >= MIN_CHARS ? text : "";
|
||||
}
|
||||
// Fallback: overall confidence + count of word-shaped tokens.
|
||||
const raw = (data.text ?? "").trim();
|
||||
const conf = typeof data.confidence === "number" ? data.confidence : 0;
|
||||
const realWords = raw.match(/[A-Za-z]{3,}/g) ?? [];
|
||||
return conf >= 72 && realWords.length >= 6 ? raw : "";
|
||||
}
|
||||
|
||||
async function ocr(_item: MediaItem, image: ImageSource): Promise<string> {
|
||||
const worker = await ensureOcrWorker();
|
||||
if (!worker) return "";
|
||||
try {
|
||||
// Request the block hierarchy so per-word confidence is available.
|
||||
const { data } = (await worker.recognize(
|
||||
image as unknown as HTMLImageElement,
|
||||
{},
|
||||
{ text: true, blocks: true },
|
||||
)) as { data: OcrData };
|
||||
return meaningfulText(data);
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] OCR failed.", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic search — CLIP via transformers.js (ONNX-WASM), in-browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLIP_MODEL_ID = "Xenova/clip-vit-base-patch16";
|
||||
|
||||
type Transformers = typeof import("@huggingface/transformers");
|
||||
|
||||
let transformersMod: Transformers | null = null;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- transformers.js pipelines are untyped */
|
||||
let clipVisionPromise: Promise<{ processor: any; model: any } | null> | null = null;
|
||||
let clipTextPromise: Promise<{ tokenizer: any; model: any } | null> | null = null;
|
||||
|
||||
async function loadTransformers(): Promise<Transformers> {
|
||||
if (!transformersMod) {
|
||||
transformersMod = await import("@huggingface/transformers");
|
||||
// Remote-only (models from the HF CDN); rely on browser cache between sessions.
|
||||
transformersMod.env.allowLocalModels = false;
|
||||
}
|
||||
return transformersMod;
|
||||
}
|
||||
|
||||
function ensureClipVision() {
|
||||
clipVisionPromise ??= (async () => {
|
||||
try {
|
||||
const tf = await loadTransformers();
|
||||
const [processor, model] = await Promise.all([
|
||||
tf.AutoProcessor.from_pretrained(CLIP_MODEL_ID),
|
||||
tf.CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL_ID),
|
||||
]);
|
||||
return { processor, model };
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] CLIP vision load failed; semantic search disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return clipVisionPromise;
|
||||
}
|
||||
|
||||
function ensureClipText() {
|
||||
clipTextPromise ??= (async () => {
|
||||
try {
|
||||
const tf = await loadTransformers();
|
||||
const [tokenizer, model] = await Promise.all([
|
||||
tf.AutoTokenizer.from_pretrained(CLIP_MODEL_ID),
|
||||
tf.CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL_ID),
|
||||
]);
|
||||
return { tokenizer, model };
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] CLIP text load failed; semantic search disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return clipTextPromise;
|
||||
}
|
||||
|
||||
/** Draw an image onto a canvas (downscaled) for the CLIP image processor. */
|
||||
function toCanvas(image: ImageSource, maxDim = 384): HTMLCanvasElement {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(w * scale));
|
||||
canvas.height = Math.max(1, Math.round(h * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported.");
|
||||
ctx.drawImage(image as CanvasImageSource, 0, 0, canvas.width, canvas.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function tensorToArray(t: any): number[] {
|
||||
const data: Float32Array = t?.data ?? t;
|
||||
return Array.from(data as ArrayLike<number>);
|
||||
}
|
||||
|
||||
async function embedImage(_item: MediaItem, image: ImageSource): Promise<number[]> {
|
||||
const v = await ensureClipVision();
|
||||
if (!v) return [];
|
||||
try {
|
||||
const tf = await loadTransformers();
|
||||
const canvas = toCanvas(image);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return [];
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const raw = new tf.RawImage(imageData.data, canvas.width, canvas.height, 4).rgb();
|
||||
const inputs = await v.processor(raw);
|
||||
const out = await v.model(inputs);
|
||||
return tensorToArray(out.image_embeds);
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] embedImage failed.", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function embedText(query: string): Promise<number[]> {
|
||||
const t = await ensureClipText();
|
||||
if (!t) return [];
|
||||
try {
|
||||
const inputs = t.tokenizer([query], { padding: true, truncation: true });
|
||||
const out = await t.model(inputs);
|
||||
return tensorToArray(out.text_embeds);
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] embedText failed.", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `true` only for the literal string "true", matching the SDK demo's semantics. */
|
||||
function flag(v: string | undefined): boolean {
|
||||
return v === "true";
|
||||
}
|
||||
|
||||
export function createCrmAIProvider(): AIProvider {
|
||||
// NEXT_PUBLIC_* are inlined at build time, so these must be read as full
|
||||
// static member expressions — do NOT refactor to dynamic indexing.
|
||||
const useRunpodDetect = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_DETECT);
|
||||
const useRunpodBg = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_BG);
|
||||
const useRunpodTilt = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_TILT);
|
||||
|
||||
return {
|
||||
name: "crm-ai (coco-ssd/yolo + face-api + tesseract + clip + runpod-edit)",
|
||||
|
||||
detectObjects: useRunpodDetect ? detectObjectsViaRunpod : detectObjectsInBrowser,
|
||||
detectFaces,
|
||||
ocr,
|
||||
embedImage,
|
||||
embedText,
|
||||
|
||||
async generativeEdit(item: MediaItem, image: ImageSource, op: GenerativeEditOp) {
|
||||
// Remove Background runs fully in-browser (no key) via @imgly — works even
|
||||
// with no backend. Other ops go through the /api/gallery/ai/edit route.
|
||||
if (op.type === "remove-background") {
|
||||
// Prefer the RunPod U²-Net endpoint when enabled; fall back to in-browser
|
||||
// @imgly if it's off or the request fails, so this always produces a result.
|
||||
if (useRunpodBg) {
|
||||
try {
|
||||
const { data, mimeType } = imageToBase64(image, 1600);
|
||||
const res = await fetch(`${API}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageBase64: data,
|
||||
mimeType,
|
||||
op: { type: "remove-background" },
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { imageBase64: out, mimeType: outMime } = (await res.json()) as {
|
||||
imageBase64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
return base64ToBlob(out, outMime || "image/png");
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the in-browser remover */
|
||||
}
|
||||
}
|
||||
const inputBlob = await canvasBlob(image, 1600);
|
||||
const { removeBackground } = await import("@imgly/background-removal");
|
||||
return removeBackground(inputBlob, { output: { format: "image/png" } });
|
||||
}
|
||||
|
||||
// Outpaint / expand-canvas: pad the image with a neutral border, mark that
|
||||
// border WHITE in the mask, and run it through the same inpaint path as
|
||||
// generative-fill — no extra backend route needed.
|
||||
if (op.type === "outpaint") {
|
||||
const { imageBase64: padded, maskBase64: border } = padForOutpaint(
|
||||
image,
|
||||
typeof op.factor === "number" ? op.factor : 1.5,
|
||||
);
|
||||
const outParams: Record<string, unknown> = {
|
||||
strength: typeof op.strength === "number" ? op.strength : 0.85,
|
||||
};
|
||||
const outRes = await fetch(`${API}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageBase64: padded,
|
||||
mimeType: "image/png",
|
||||
op: {
|
||||
type: "generative-fill",
|
||||
prompt:
|
||||
op.prompt ||
|
||||
"Extend and continue the scene naturally, matching lighting, colors and perspective.",
|
||||
},
|
||||
maskBase64: border,
|
||||
params: outParams,
|
||||
}),
|
||||
});
|
||||
if (!outRes.ok) {
|
||||
const err = (await outRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `AI request failed (${outRes.status}).`);
|
||||
}
|
||||
const outJson = (await outRes.json()) as { imageBase64: string; mimeType?: string };
|
||||
return base64ToBlob(outJson.imageBase64, outJson.mimeType || "image/png");
|
||||
}
|
||||
|
||||
const { data, mimeType, width, height } = imageToBase64(image, 1280);
|
||||
// Masked ops carry an ImageData mask — rasterize it to a PNG matched to the
|
||||
// (downscaled) image dims, and strip it from the op since ImageData is not
|
||||
// JSON-serializable.
|
||||
const maskBase64 = "mask" in op ? maskToBase64(op.mask, width, height) : undefined;
|
||||
const wireOp: Record<string, unknown> = { type: op.type };
|
||||
if ("prompt" in op && typeof op.prompt === "string") wireOp.prompt = op.prompt;
|
||||
if ("factor" in op && typeof op.factor === "number") wireOp.factor = op.factor;
|
||||
|
||||
// Forward the "edit strength" slider (0..1) so the backend can scale the edit.
|
||||
const params: Record<string, unknown> = {};
|
||||
if ("strength" in op && typeof op.strength === "number") params.strength = op.strength;
|
||||
|
||||
const res = await fetch(`${API}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ imageBase64: data, mimeType, op: wireOp, maskBase64, params }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `AI request failed (${res.status}).`);
|
||||
}
|
||||
const { imageBase64, mimeType: outMime } = (await res.json()) as {
|
||||
imageBase64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
return base64ToBlob(imageBase64, outMime || "image/png");
|
||||
},
|
||||
|
||||
// Voice annotation: record → (optional denoise) → transcribe. Both proxy
|
||||
// through server routes so the RunPod key stays server-side.
|
||||
async transcribeAudio(audioBase64: string) {
|
||||
const res = await fetch(`${API}/transcribe`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audio: audioBase64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `Transcription failed (${res.status}).`);
|
||||
}
|
||||
const { transcript } = (await res.json()) as { transcript?: string };
|
||||
return (transcript ?? "").trim();
|
||||
},
|
||||
|
||||
async denoiseAudio(audioBase64: string) {
|
||||
const res = await fetch(`${API}/denoise`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audio: audioBase64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `Denoise failed (${res.status}).`);
|
||||
}
|
||||
const { audio } = (await res.json()) as { audio?: string };
|
||||
return audio ?? audioBase64;
|
||||
},
|
||||
|
||||
// Camera-tilt estimation is opt-in (needs the RunPod tilt endpoint deployed);
|
||||
// gate it so the editor's Auto-straighten button only appears when configured.
|
||||
estimateTilt: useRunpodTilt
|
||||
? async (_item: MediaItem, image: ImageSource) => {
|
||||
const { data } = imageToBase64(image, 1024);
|
||||
const res = await fetch(`${API}/tilt`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ image: data }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `Tilt estimate failed (${res.status}).`);
|
||||
}
|
||||
return (await res.json()) as {
|
||||
rollDegrees: number;
|
||||
pitchDegrees: number;
|
||||
fovDegrees: number;
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
// Smart Gallery data layer. Serves EITHER a device-local mock (when the Shell isn't configured — the
|
||||
// demo keeps working offline) OR the live be-crm data door (crm.gallery.*), behind one StorageAdapter
|
||||
// so the embedded @photo-gallery/sdk is mode-agnostic. Tenant scoping, comment authorship and byte
|
||||
// authorization are all enforced server-side; this is just glue.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.gallery.state.load {} -> PersistedState
|
||||
// cmd crm.gallery.state.apply StateChanges -> { upserted, removed }
|
||||
// cmd crm.gallery.media.presignUpload { mediaId, mime, sizeBytes, filename? } -> { ref, uploadUrl, method, headers? }
|
||||
// cmd crm.gallery.media.presignDownload { refs: string[] } -> { urls, expiresInSeconds }
|
||||
// query crm.gallery.stats {} -> { items, albums, people, bytes }
|
||||
// query crm.gallery.lock.status {} -> { hasPassword }
|
||||
// cmd crm.gallery.lock.set { password: string | null } -> { hasPassword }
|
||||
// cmd crm.gallery.lock.verify { password: string } -> { ok }
|
||||
//
|
||||
// BYTES NEVER PASS THROUGH be-crm OR THE BFF. `putMedia` mints a short-lived signed PUT URL and the
|
||||
// browser transfers straight to object storage — the same rule media-api.ts follows for attachments.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import {
|
||||
createLocalStorageAdapter,
|
||||
type GalleryFeatures,
|
||||
type GalleryUser,
|
||||
type MediaItem,
|
||||
type PersistedState,
|
||||
type StateChanges,
|
||||
type StorageAdapter,
|
||||
type StoredBlob,
|
||||
type ThemeTokens,
|
||||
} from "@photo-gallery/sdk";
|
||||
import { user as demoUser } from "@/components/dashboard/account-data";
|
||||
import { useMyAccess } from "@/lib/access";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
/** Device-local store key for demo mode. Namespaced so it can't collide with the SDK's own demo. */
|
||||
const LOCAL_STORE_KEY = "lup:smart-gallery:v1";
|
||||
|
||||
/** be-crm caps a single presignDownload at 500 refs — chunk anything larger. */
|
||||
const PRESIGN_CHUNK = 500;
|
||||
|
||||
/** Largest single upload the gallery will attempt (matches be-crm's gallery cap). */
|
||||
export const MAX_GALLERY_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Wire types (be-crm shapes) */
|
||||
/* ======================================================================== */
|
||||
|
||||
interface StateLoadDTO {
|
||||
media: MediaItem[];
|
||||
albums: PersistedState["albums"];
|
||||
people: PersistedState["people"];
|
||||
labelAliases?: Record<string, string>;
|
||||
deletedLabels?: string[];
|
||||
}
|
||||
|
||||
interface PresignUploadDTO {
|
||||
ref: string;
|
||||
uploadUrl: string;
|
||||
method: "PUT";
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PresignDownloadDTO {
|
||||
urls: Record<string, string>;
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
/** The subset of the AppShell SDK this module needs — keeps the adapter unit-testable. */
|
||||
interface DataDoor {
|
||||
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* The live adapter — be-crm data door */
|
||||
/* ======================================================================== */
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A StorageAdapter backed by the be-crm data door.
|
||||
*
|
||||
* Metadata (media/albums/people) rides the door as JSON; bytes go direct to object storage via
|
||||
* short-lived signed URLs. `storageRef` is the durable handle we persist — `src` is only ever a
|
||||
* signed URL with a TTL, so it is re-resolved from the refs on every `load()`.
|
||||
*/
|
||||
export function createDataDoorAdapter(sdk: DataDoor): StorageAdapter {
|
||||
/** Resolve durable refs → fresh signed GET URLs, in chunks the door will accept. */
|
||||
async function resolveRefs(refs: string[]): Promise<Record<string, string>> {
|
||||
const unique = [...new Set(refs.filter(Boolean))];
|
||||
if (!unique.length) return {};
|
||||
const urls: Record<string, string> = {};
|
||||
for (const group of chunk(unique, PRESIGN_CHUNK)) {
|
||||
const res = await sdk.command<PresignDownloadDTO>("crm.gallery.media.presignDownload", { refs: group });
|
||||
Object.assign(urls, res.urls ?? {});
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "crm-data-door",
|
||||
|
||||
async load(): Promise<PersistedState | null> {
|
||||
const state = await sdk.query<StateLoadDTO>("crm.gallery.state.load", {});
|
||||
const media = state.media ?? [];
|
||||
// Signed URLs expire, so `src` is always rebuilt from `storageRef` at load time. Items with no
|
||||
// ref (e.g. a seeded remote URL) keep whatever `src` they were stored with.
|
||||
const urls = await resolveRefs(media.map((m) => m.storageRef ?? "").filter(Boolean));
|
||||
return {
|
||||
media: media.map((m) => (m.storageRef && urls[m.storageRef] ? { ...m, src: urls[m.storageRef]! } : m)),
|
||||
albums: state.albums ?? [],
|
||||
people: state.people ?? [],
|
||||
labelAliases: state.labelAliases ?? {},
|
||||
deletedLabels: state.deletedLabels ?? [],
|
||||
version: 1,
|
||||
};
|
||||
},
|
||||
|
||||
// Incremental sync is the real path (see applyChanges). `save` only runs if the store ever falls
|
||||
// back to whole-state persistence; express it as one big change set so behaviour is identical.
|
||||
async save(state: PersistedState): Promise<void> {
|
||||
await sdk.command("crm.gallery.state.apply", {
|
||||
upsertMedia: state.media,
|
||||
upsertAlbums: state.albums,
|
||||
upsertPeople: state.people,
|
||||
labelAliases: state.labelAliases ?? {},
|
||||
deletedLabels: state.deletedLabels ?? [],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Incremental persistence — only the entities that actually changed. This is what makes a shared
|
||||
* tenant library safe for concurrent editors: two people touching different photos never clobber
|
||||
* each other, because neither sends the other's rows.
|
||||
*/
|
||||
async applyChanges(changes: StateChanges): Promise<void> {
|
||||
// StateChanges is a closed interface; the door takes an open variables bag.
|
||||
await sdk.command("crm.gallery.state.apply", { ...changes });
|
||||
},
|
||||
|
||||
/** Presign → direct PUT → resolve a display URL. Bytes never touch be-crm or the BFF. */
|
||||
async putMedia(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob> {
|
||||
if (blob.size > MAX_GALLERY_BYTES) {
|
||||
throw new Error(`File is too large (max ${Math.floor(MAX_GALLERY_BYTES / (1024 * 1024))} MB).`);
|
||||
}
|
||||
const mime = meta.mime || blob.type || "application/octet-stream";
|
||||
const presigned = await sdk.command<PresignUploadDTO>("crm.gallery.media.presignUpload", {
|
||||
mediaId: id,
|
||||
mime,
|
||||
sizeBytes: blob.size,
|
||||
filename: meta.name,
|
||||
});
|
||||
const res = await fetch(presigned.uploadUrl, {
|
||||
method: presigned.method ?? "PUT",
|
||||
headers: { "content-type": mime, ...(presigned.headers ?? {}) },
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||
const urls = await resolveRefs([presigned.ref]);
|
||||
return { ref: presigned.ref, url: urls[presigned.ref] ?? presigned.ref };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Public hooks */
|
||||
/* ======================================================================== */
|
||||
|
||||
export interface GalleryStorage {
|
||||
/** True when persisting to be-crm; false when running on the device-local demo store. */
|
||||
live: boolean;
|
||||
adapter: StorageAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage backend for the embedded gallery. Live against the be-crm data door once the Shell is
|
||||
* configured, otherwise a device-local store so the demo works with no backend at all.
|
||||
*
|
||||
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
|
||||
*/
|
||||
export function useGalleryStorage(): GalleryStorage {
|
||||
const { sdk } = useAppShell();
|
||||
// The adapter identity must be stable — PhotoGallery captures it in a ref on first render.
|
||||
return useMemo<GalleryStorage>(
|
||||
() =>
|
||||
SHELL
|
||||
? { live: true, adapter: createDataDoorAdapter(sdk as unknown as DataDoor) }
|
||||
: { live: false, adapter: createLocalStorageAdapter(LOCAL_STORE_KEY) },
|
||||
[sdk],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in identity handed to the gallery so comments are attributed to a real person rather
|
||||
* than a free-text name the author can type themselves.
|
||||
*
|
||||
* Falls back to the same static demo user the sidebar and topbar use when the Shell isn't wired, so
|
||||
* the comment module behaves identically in the demo — the CRM never shows an anonymous author.
|
||||
* be-crm re-stamps `authorId` from the PAT on every write regardless, so this value is a display
|
||||
* convenience, never the source of authority.
|
||||
*/
|
||||
export function useGalleryUser(): GalleryUser {
|
||||
const { user, context } = useAuth();
|
||||
return useMemo<GalleryUser>(() => {
|
||||
if (!user) return { id: demoUser.id, name: demoUser.name };
|
||||
const avatarUrl = user.avatarUrl ?? context?.principal?.avatarUrl;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.displayName || user.email || demoUser.name,
|
||||
...(user.email ? { email: user.email } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
};
|
||||
}, [user, context]);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Recently Deleted lock */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* The SDK's `lockProvider` contract, declared here rather than imported so this module keeps
|
||||
* compiling against an SDK build that predates the prop. It is structurally identical to
|
||||
* `PhotoGalleryProps['lockProvider']`.
|
||||
*/
|
||||
export interface GalleryLockProvider {
|
||||
status(): Promise<{ hasPassword: boolean }>;
|
||||
/** `null` clears the password. */
|
||||
set(password: string | null): Promise<void>;
|
||||
verify(password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-backed, per-user lock for the Recently Deleted view.
|
||||
*
|
||||
* Without this the SDK falls back to a device-local `localStorage` hash: the lock exists only on
|
||||
* the browser that set it, so the same account on a second device sees an unlocked trash. Backed
|
||||
* by `crm.gallery.lock.*`, the password becomes an account property — be-crm stores only a
|
||||
* scrypt hash with a per-record random salt, keyed by (tenant, principal), and rate-limits verify.
|
||||
*
|
||||
* Returns `undefined` in demo mode ON PURPOSE: with no backend there is nowhere to put the hash,
|
||||
* and the SDK's own device-local behaviour is the right fallback for a demo.
|
||||
*
|
||||
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
|
||||
*/
|
||||
export function useGalleryLockProvider(): GalleryLockProvider | undefined {
|
||||
const { sdk } = useAppShell();
|
||||
return useMemo<GalleryLockProvider | undefined>(() => {
|
||||
if (!SHELL) return undefined;
|
||||
const door = sdk as unknown as DataDoor;
|
||||
return {
|
||||
status: () => door.query<{ hasPassword: boolean }>("crm.gallery.lock.status", {}),
|
||||
// The SDK's contract returns void; the door's `{ hasPassword }` is redundant after a set.
|
||||
set: async (password) => {
|
||||
await door.command("crm.gallery.lock.set", { password });
|
||||
},
|
||||
// A wrong password is a normal `{ ok: false }`, not an error. A 403 (the verify lockout)
|
||||
// still throws, which is what the SDK's prompt should surface.
|
||||
verify: async (password) => (await door.command<{ ok: boolean }>("crm.gallery.lock.verify", { password })).ok,
|
||||
};
|
||||
}, [sdk]);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Permission-gated features */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* The gallery capabilities the CRM gates behind team permissions. Each SDK feature toggle
|
||||
* (and the whole-view `media.view` gate) maps to one permission id from be-crm's Media group.
|
||||
* The SDK still enforces nothing here — hiding a control is UX; be-crm enforces every write via
|
||||
* GALLERY_VISIBILITY + tenant scoping regardless of what the UI shows.
|
||||
*/
|
||||
const MEDIA_PERMISSIONS = [
|
||||
"media.view", "media.upload", "media.capture", "media.edit",
|
||||
"media.delete", "media.export", "media.share", "media.map", "media.ai",
|
||||
] as const;
|
||||
|
||||
/** SDK feature toggle → the permission id that unlocks it. */
|
||||
const FEATURE_PERMISSION: Record<keyof GalleryFeatures, string> = {
|
||||
editor: "media.edit",
|
||||
camera: "media.capture",
|
||||
import: "media.upload",
|
||||
export: "media.export",
|
||||
sharing: "media.share",
|
||||
map: "media.map",
|
||||
ai: "media.ai",
|
||||
};
|
||||
|
||||
export interface ResolvedGalleryFeatures {
|
||||
/** Feature toggles to hand the SDK's `features` prop, resolved from the caller's permissions. */
|
||||
features: GalleryFeatures;
|
||||
/** Whether the whole Smart Gallery view should render at all (`media.view`). */
|
||||
canView: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the gallery's feature flags + view access from the signed-in user's CRM permissions.
|
||||
*
|
||||
* Fallback rule (SAFE default — a control is enabled unless the user is positively known to lack it):
|
||||
* 1. While access is still loading, stay permissive so features don't flash on then vanish.
|
||||
* 2. Superadmins and owners get everything (they hold every permission anyway; this is explicit).
|
||||
* 3. A member who has been assigned AT LEAST ONE `media.*` permission is gated precisely: each
|
||||
* feature is enabled only if its mapped permission is in their list.
|
||||
* 4. A member with ZERO `media.*` permissions assigned is treated as fully enabled. Roles are not
|
||||
* configured with Media perms until an admin opts in (§5 just made them assignable), so gating a
|
||||
* freshly-seeded member down to nothing would cripple the gallery before anyone could grant them
|
||||
* anything. The "has at least one media.* perm" signal is what flips a role from this permissive
|
||||
* default into precise per-feature gating.
|
||||
*
|
||||
* Because be-crm's mock access (demo, no Shell) returns superadmin + all perms, the demo shows
|
||||
* everything via rule 2.
|
||||
*/
|
||||
export function useGalleryFeatures(): ResolvedGalleryFeatures {
|
||||
const access = useMyAccess();
|
||||
return useMemo<ResolvedGalleryFeatures>(() => {
|
||||
const has = (p: string) => access.permissions.includes(p);
|
||||
const privileged = access.isSuperadmin || access.roleSlugs.includes("owner");
|
||||
const hasAnyMedia = MEDIA_PERMISSIONS.some(has);
|
||||
// Permissive whenever we can't (yet) prove the user lacks a permission: loading, privileged, or a
|
||||
// member who has no Media perms assigned at all. Otherwise gate precisely on the mapped id.
|
||||
const allow = (perm: string) => access.loading || privileged || !hasAnyMedia || has(perm);
|
||||
|
||||
const features: GalleryFeatures = {
|
||||
editor: allow(FEATURE_PERMISSION.editor),
|
||||
camera: allow(FEATURE_PERMISSION.camera),
|
||||
ai: allow(FEATURE_PERMISSION.ai),
|
||||
map: allow(FEATURE_PERMISSION.map),
|
||||
import: allow(FEATURE_PERMISSION.import),
|
||||
export: allow(FEATURE_PERMISSION.export),
|
||||
sharing: allow(FEATURE_PERMISSION.sharing),
|
||||
};
|
||||
|
||||
return { features, canView: allow("media.view") };
|
||||
}, [access]);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Theme bridge */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Maps the gallery's design tokens onto the CRM's own CSS variables.
|
||||
*
|
||||
* Every value is a `var(--crm-token)` reference rather than a literal hex, so the gallery inherits the
|
||||
* dashboard's palette through the SAME `[data-theme]` cascade the rest of the app uses — light/dark
|
||||
* switch together, and a future palette change reaches the gallery with no code edit here.
|
||||
*/
|
||||
export const GALLERY_THEME_TOKENS: ThemeTokens = {
|
||||
// Surfaces
|
||||
bgLight: "var(--bg)",
|
||||
bgDark: "var(--bg)",
|
||||
elevatedLight: "var(--panel-3)",
|
||||
elevatedDark: "var(--panel-3)",
|
||||
sidebarBgLight: "var(--sidebar)",
|
||||
sidebarBgDark: "var(--sidebar)",
|
||||
toolbarBgLight: "var(--panel)",
|
||||
toolbarBgDark: "var(--panel)",
|
||||
cardLight: "var(--panel)",
|
||||
cardDark: "var(--panel)",
|
||||
cardHoverLight: "var(--panel-3)",
|
||||
cardHoverDark: "var(--panel-3)",
|
||||
menuBgLight: "var(--panel-3)",
|
||||
menuBgDark: "var(--panel-3)",
|
||||
|
||||
// Text
|
||||
textLight: "var(--text)",
|
||||
textDark: "var(--text)",
|
||||
textSecondaryLight: "var(--muted)",
|
||||
textSecondaryDark: "var(--muted)",
|
||||
textTertiaryLight: "var(--faint)",
|
||||
textTertiaryDark: "var(--faint)",
|
||||
|
||||
// Lines + washes
|
||||
separatorLight: "var(--border)",
|
||||
separatorDark: "var(--border)",
|
||||
separatorStrongLight: "var(--border-2)",
|
||||
separatorStrongDark: "var(--border-2)",
|
||||
hoverLight: "var(--track)",
|
||||
hoverDark: "var(--track)",
|
||||
activeLight: "var(--border-2)",
|
||||
activeDark: "var(--border-2)",
|
||||
sidebarSelectedLight: "var(--track)",
|
||||
sidebarSelectedDark: "var(--track)",
|
||||
glassBorderLight: "var(--border-2)",
|
||||
glassBorderDark: "var(--border-2)",
|
||||
|
||||
// Brand
|
||||
accent: "var(--orange)",
|
||||
accentStrongLight: "var(--orange-2)",
|
||||
accentStrongDark: "var(--orange-2)",
|
||||
accentContrast: "#ffffff",
|
||||
dangerLight: "var(--red)",
|
||||
dangerDark: "var(--red)",
|
||||
tileFav: "var(--red)",
|
||||
segmentedActive: "var(--panel-3)",
|
||||
|
||||
// Overlays + chrome
|
||||
overlayBg: "rgba(2,2,6,0.94)",
|
||||
editorBg: "var(--panel-2)",
|
||||
shadowSm: "0 1px 2px rgba(0,0,0,0.18)",
|
||||
shadowMdLight: "var(--shadow)",
|
||||
shadowMdDark: "var(--shadow)",
|
||||
shadowLgLight: "0 30px 70px -20px rgba(15,23,42,0.25)",
|
||||
shadowLgDark: "0 30px 70px -20px rgba(0,0,0,0.7)",
|
||||
|
||||
fontFamily: "var(--font)",
|
||||
radiusMenu: 14,
|
||||
sidebarRadius: 14,
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Inbox data layer. The inbox is a personalized work/awareness feed IIOS projects from events
|
||||
// (NEEDS_REPLY, MENTION, …). The CRM lists it and transitions item state; items are never created
|
||||
// here. Mock when the Shell isn't configured; live via the be-crm data door (crm.inbox.*) otherwise.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.inbox.list { state? } -> InboxItem[]
|
||||
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
import type { MailThread } from "./mail-api";
|
||||
|
||||
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
|
||||
export interface UiInboxItem {
|
||||
id: string; kind: string; state: InboxState; title: string; summary?: string;
|
||||
priority: string; threadId?: string; createdAt: string;
|
||||
}
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export interface InboxData {
|
||||
live: boolean; loading: boolean; error: string | null;
|
||||
items: UiInboxItem[];
|
||||
transition: (id: string, state: InboxState) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useInboxData(state?: InboxState): InboxData {
|
||||
return SHELL ? useLiveInbox(state) : useMockInbox(state);
|
||||
}
|
||||
|
||||
function useLiveInbox(state?: InboxState): InboxData {
|
||||
const { sdk } = useAppShell();
|
||||
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
|
||||
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
|
||||
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
|
||||
const showMail = !state || state === "OPEN";
|
||||
const mq = useQuery<MailThread[]>("crm.mail.list", {});
|
||||
|
||||
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
|
||||
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
|
||||
const refetchInbox = q.refetch;
|
||||
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
|
||||
|
||||
const items = useMemo<UiInboxItem[]>(() => {
|
||||
const inboxItems = q.data ?? [];
|
||||
const mailItems: UiInboxItem[] = showMail
|
||||
? (mq.data ?? []).map((t) => ({
|
||||
id: `mail:${t.threadId}`,
|
||||
kind: "MAIL",
|
||||
state: "OPEN" as InboxState,
|
||||
title: t.subject || "(no subject)",
|
||||
...(t.lastMessage ? { summary: t.lastMessage } : {}),
|
||||
priority: t.unread > 0 ? "HIGH" : "LOW",
|
||||
threadId: t.threadId,
|
||||
createdAt: t.lastAt ?? "",
|
||||
}))
|
||||
: [];
|
||||
// Newest first; mail and inbox items interleave by time.
|
||||
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
||||
}, [q.data, mq.data, showMail]);
|
||||
|
||||
const transition = useCallback(async (id: string, next: InboxState) => {
|
||||
await sdk.command("crm.inbox.transition", { id, state: next });
|
||||
q.refetch();
|
||||
}, [sdk, q]);
|
||||
|
||||
return {
|
||||
live: true,
|
||||
loading: q.loading || (showMail && mq.loading),
|
||||
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
|
||||
error: q.error?.message ?? null,
|
||||
items,
|
||||
transition,
|
||||
refetch: () => { q.refetch(); mq.refetch(); },
|
||||
};
|
||||
}
|
||||
|
||||
const MOCK_ITEMS: UiInboxItem[] = [
|
||||
{ id: "in_1", kind: "MENTION", state: "OPEN", title: "Sofia mentioned you", summary: "@you — can you confirm the Henderson scope?", priority: "HIGH", threadId: "th_mock_1", createdAt: new Date().toISOString() },
|
||||
{ id: "in_2", kind: "NEEDS_REPLY", state: "OPEN", title: "Reply needed — Storm response", summary: "Dan: Crew is rolling out at 7.", priority: "MEDIUM", threadId: "th_mock_2", createdAt: new Date().toISOString() },
|
||||
{ id: "in_3", kind: "SUPPORT_UPDATE", state: "OPEN", title: "Ticket TK-204 updated", summary: "Customer replied on the roof-leak case.", priority: "LOW", createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
function useMockInbox(state?: InboxState): InboxData {
|
||||
const [items, setItems] = useState<UiInboxItem[]>(MOCK_ITEMS);
|
||||
const filtered = useMemo(() => (state ? items.filter((i) => i.state === state) : items), [items, state]);
|
||||
const transition = useCallback(async (id: string, next: InboxState) => {
|
||||
setItems((l) => l.map((i) => (i.id === id ? { ...i, state: next } : i)));
|
||||
}, []);
|
||||
return { live: false, loading: false, error: null, items: filtered, transition, refetch: () => {} };
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Mail data layer. A dedicated Mail reader over the be-crm data door (crm.mail.*), distinct from
|
||||
// the Messenger chat and from the work-item Inbox. Live via the AppShell SDK; a small mock keeps the
|
||||
// demo working before the Shell + be-crm are connected.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.mail.list {} -> MailThread[]
|
||||
// query crm.mail.history { threadId } -> MailMessage[]
|
||||
// cmd crm.mail.reply { threadId, content } -> { interactionId, threadId }
|
||||
// cmd crm.mail.internal { recipientUserId, subject?, text?, html? } -> { threadId }
|
||||
// cmd crm.mail.send { target, subject?, text?, html?, mirrorToUserId? } -> { commandId }
|
||||
// query crm.messenger.directory { kind, limit } -> people to compose to (reused)
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
export interface MailThread {
|
||||
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||
}
|
||||
export interface MailAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null }
|
||||
export interface MailMessage {
|
||||
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; attachment: MailAttachment | null;
|
||||
}
|
||||
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
|
||||
|
||||
/** Shape produced by media-api's useUploadAttachment, passed into a reply/compose. */
|
||||
export interface OutgoingAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
/* ============================ Thread list ============================ */
|
||||
|
||||
export interface MailListData {
|
||||
live: boolean; loading: boolean; error: string | null; threads: MailThread[]; refetch: () => void;
|
||||
}
|
||||
|
||||
export function useMailThreads(): MailListData {
|
||||
if (SHELL) {
|
||||
const q = useQuery<MailThread[]>("crm.mail.list", {});
|
||||
return { live: true, loading: q.loading, error: q.error?.message ?? null, threads: q.data ?? [], refetch: q.refetch };
|
||||
}
|
||||
return { live: false, loading: false, error: null, threads: MOCK_THREADS, refetch: () => {} };
|
||||
}
|
||||
|
||||
/* ============================ One thread ============================ */
|
||||
|
||||
export interface MailThreadData {
|
||||
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string, attachment?: OutgoingAttachment) => Promise<void>; refetch: () => void;
|
||||
}
|
||||
|
||||
export function useMailThread(threadId: string | null): MailThreadData {
|
||||
if (SHELL) return useLiveThread(threadId);
|
||||
return useMockThread(threadId);
|
||||
}
|
||||
|
||||
function useLiveThread(threadId: string | null): MailThreadData {
|
||||
const { sdk } = useAppShell();
|
||||
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
|
||||
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
|
||||
if (!threadId) return;
|
||||
await sdk.command("crm.mail.reply", { threadId, content, ...(attachment ? { attachment } : {}) });
|
||||
q.refetch();
|
||||
}, [sdk, threadId, q]);
|
||||
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
|
||||
}
|
||||
|
||||
/* ============================ Compose ============================ */
|
||||
|
||||
export interface ComposeData {
|
||||
directory: MailPerson[];
|
||||
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => Promise<void>;
|
||||
sendExternal: (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useMailCompose(onSent: () => void): ComposeData {
|
||||
if (SHELL) {
|
||||
const { sdk } = useAppShell();
|
||||
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
||||
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
|
||||
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => {
|
||||
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(attachments && attachments.length ? { attachments } : {}) });
|
||||
onSent();
|
||||
}, [sdk, onSent]);
|
||||
const sendExternal = useCallback(async (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => {
|
||||
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(opts?.mirrorToUserId ? { mirrorToUserId: opts.mirrorToUserId } : {}), ...(opts?.attachments && opts.attachments.length ? { attachments: opts.attachments } : {}) });
|
||||
onSent();
|
||||
}, [sdk, onSent]);
|
||||
return { directory, sendInternal, sendExternal };
|
||||
}
|
||||
return { directory: MOCK_PEOPLE, sendInternal: async () => onSent(), sendExternal: async () => onSent() };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/* ============================ Mock (demo mode) ============================ */
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const MOCK_PEOPLE: MailPerson[] = [
|
||||
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
|
||||
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
|
||||
];
|
||||
const MOCK_THREADS: MailThread[] = [
|
||||
{ threadId: "mt_1", subject: "Welcome to the Founders Club", participants: ["you", "system"], unread: 1, lastMessage: "Thanks for joining…", lastAt: now() },
|
||||
{ threadId: "mt_2", subject: "Storm response — East side", participants: ["you", "pp_sofia"], unread: 0, lastMessage: "Crew rolling out at 7", lastAt: now() },
|
||||
];
|
||||
function useMockThread(threadId: string | null): MailThreadData {
|
||||
const [extra, setExtra] = useState<MailMessage[]>([]);
|
||||
const base: MailMessage[] = threadId === "mt_1"
|
||||
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club.", attachment: null }]
|
||||
: threadId === "mt_2"
|
||||
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7.", attachment: null }]
|
||||
: [];
|
||||
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
|
||||
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content, attachment: attachment ? { contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, filename: attachment.filename } : null }]);
|
||||
}, []);
|
||||
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
|
||||
}
|
||||
+14
-1
@@ -14,12 +14,25 @@ export function isImage(mime?: string | null): boolean {
|
||||
return !!mime && mime.startsWith("image/");
|
||||
}
|
||||
|
||||
// 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}).`);
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Messenger data layer. Serves EITHER a local mock (when the Shell isn't configured — the demo
|
||||
// keeps working) OR the live be-crm data door (crm.messenger.*), behind one interface so the UI is
|
||||
// mode-agnostic. DM-vs-group + who-can-chat are enforced server-side by IIOS/OPA; this is just glue.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.messenger.directory { kind, query?, limit } -> DirectoryEntry[]
|
||||
// query crm.messenger.conversation.list {} -> ConversationSummary[]
|
||||
// cmd crm.messenger.conversation.open { participantIds[], membership?, subject? } -> { threadId, ... }
|
||||
// query crm.messenger.history { threadId } -> MessengerMessage[]
|
||||
// cmd crm.messenger.send { threadId, content } -> MessengerMessage
|
||||
// cmd crm.messenger.participant.add { threadId, userId }
|
||||
//
|
||||
// v1 uses REST + polling for the live stream; v2 layers the IIOS MessageSocket (messenger-socket.tsx)
|
||||
// on top for live messages, typing, read receipts, and reactions.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import type { AnnotationEvent, AnnotationGroup } from "@insignia/iios-kernel-client";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
import { useMessengerSocket } from "./messenger-socket";
|
||||
|
||||
export type Membership = "dm" | "group";
|
||||
export interface UiPerson { id: string; name: string; kind: "staff" | "customer" }
|
||||
export interface UiConversation {
|
||||
threadId: string; title: string; subject: string | null; membership: Membership | null;
|
||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||
}
|
||||
export interface UiReaction { emoji: string; count: number; mine: boolean }
|
||||
export interface UiAttachment { contentRef: string; mimeType: string; sizeBytes: number }
|
||||
export interface UiMessage {
|
||||
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
|
||||
parentInteractionId?: string | null;
|
||||
attachment?: UiAttachment;
|
||||
reactions?: UiReaction[];
|
||||
}
|
||||
|
||||
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
||||
interface ConversationDTO {
|
||||
threadId: string; subject: string | null; membership: Membership | null;
|
||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||
}
|
||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
const POLL_MS = 4000;
|
||||
const TYPING_TTL_MS = 3500;
|
||||
|
||||
const shortId = (id: string) => id.replace(/^(pp_|cust_)/, "").slice(0, 6);
|
||||
|
||||
/** Turn the kernel's generic annotation aggregates into reaction chips. `users` may hold user or
|
||||
* actor ids depending on the source, so `mine` is best-effort; a fresh annotation event corrects it. */
|
||||
export function toReactions(annotations: AnnotationGroup[] | undefined, myId?: string): UiReaction[] {
|
||||
if (!annotations) return [];
|
||||
return annotations
|
||||
.filter((a) => a.type === "reaction" && a.users.length > 0)
|
||||
.map((a) => ({ emoji: a.value, count: a.users.length, mine: !!myId && a.users.includes(myId) }));
|
||||
}
|
||||
|
||||
function applyAnnotation(prev: UiReaction[] | undefined, e: AnnotationEvent, myId?: string): UiReaction[] {
|
||||
const base = (prev ?? []).filter((r) => r.emoji !== e.value);
|
||||
if (e.type !== "reaction" || e.users.length === 0) return base;
|
||||
return [...base, { emoji: e.value, count: e.users.length, mine: !!myId && e.users.includes(myId) }];
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Public hooks */
|
||||
/* ======================================================================== */
|
||||
|
||||
export interface MessengerData {
|
||||
live: boolean; loading: boolean; error: string | null;
|
||||
directory: UiPerson[];
|
||||
conversations: UiConversation[];
|
||||
nameOf: (id: string) => string;
|
||||
openConversation: (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => Promise<string>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export interface ThreadData {
|
||||
loading: boolean; error: string | null;
|
||||
messages: UiMessage[];
|
||||
send: (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => Promise<void>;
|
||||
react: (interactionId: string, emoji: string) => void;
|
||||
typingUserIds: string[];
|
||||
seenIds: Set<string>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export interface UiMember { userId: string; displayName: string; role: string }
|
||||
export interface GroupSettingsData {
|
||||
loading: boolean; error: string | null;
|
||||
members: UiMember[];
|
||||
isAdmin: boolean;
|
||||
rename: (subject: string) => Promise<void>;
|
||||
addMember: (userId: string) => Promise<void>;
|
||||
removeMember: (userId: string) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useMessengerData(): MessengerData {
|
||||
return SHELL ? useLiveMessenger() : useMockMessenger();
|
||||
}
|
||||
export function useThread(threadId: string): ThreadData {
|
||||
return SHELL ? useLiveThread(threadId) : useMockThread(threadId);
|
||||
}
|
||||
export function useGroupSettings(threadId: string): GroupSettingsData {
|
||||
return SHELL ? useLiveGroupSettings(threadId) : useMockGroupSettings(threadId);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Live implementation (be-crm data door + IIOS socket) */
|
||||
/* ======================================================================== */
|
||||
|
||||
function useLiveMessenger(): MessengerData {
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const socket = useMessengerSocket();
|
||||
const myId = user?.id;
|
||||
const dirQ = useQuery<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
|
||||
const convQ = useQuery<ConversationDTO[]>("crm.messenger.conversation.list", {});
|
||||
|
||||
const directory: UiPerson[] = useMemo(
|
||||
() => (dirQ.data ?? []).map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })),
|
||||
[dirQ.data],
|
||||
);
|
||||
const nameById = useMemo(() => Object.fromEntries(directory.map((p) => [p.id, p.name])), [directory]);
|
||||
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
|
||||
|
||||
// Live sidebar previews: patch lastMessage/lastAt the instant a message arrives on any thread,
|
||||
// then reconcile authoritative unread/order with a debounced refetch.
|
||||
const [previews, setPreviews] = useState<Record<string, { lastMessage: string; lastAt: string }>>({});
|
||||
const refetchRef = useRef(convQ.refetch);
|
||||
refetchRef.current = convQ.refetch;
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const off = socket.onAnyMessage((threadId, m) => {
|
||||
setPreviews((p) => ({ ...p, [threadId]: { lastMessage: m.text, lastAt: m.at } }));
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => refetchRef.current(), 600);
|
||||
});
|
||||
return () => { off(); if (timer) clearTimeout(timer); };
|
||||
}, [socket]);
|
||||
|
||||
const conversations: UiConversation[] = useMemo(
|
||||
() => (convQ.data ?? []).map((c) => shape(c, nameOf, myId, previews[c.threadId])),
|
||||
[convQ.data, nameOf, myId, previews],
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => { dirQ.refetch(); convQ.refetch(); }, [dirQ, convQ]);
|
||||
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
|
||||
const res = (await sdk.command("crm.messenger.conversation.open", {
|
||||
participantIds, ...(opts?.membership ? { membership: opts.membership } : {}), ...(opts?.subject ? { subject: opts.subject } : {}),
|
||||
})) as { threadId: string };
|
||||
convQ.refetch();
|
||||
return res.threadId;
|
||||
}, [sdk, convQ]);
|
||||
|
||||
return {
|
||||
live: true,
|
||||
loading: dirQ.loading || convQ.loading,
|
||||
error: (dirQ.error ?? convQ.error)?.message ?? null,
|
||||
directory, conversations, nameOf, openConversation, refetch,
|
||||
};
|
||||
}
|
||||
|
||||
function useLiveThread(threadId: string): ThreadData {
|
||||
const { sdk } = useAppShell();
|
||||
const socket = useMessengerSocket();
|
||||
const socketReady = socket?.ready ?? false;
|
||||
const q = useQuery<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||
const [socketMsgs, setSocketMsgs] = useState<UiMessage[]>([]);
|
||||
const [myActorId, setMyActorId] = useState<string | null>(null);
|
||||
const myActorIdRef = useRef<string | null>(null);
|
||||
myActorIdRef.current = myActorId;
|
||||
const [typing, setTyping] = useState<Record<string, number>>({}); // userId -> expiry ts
|
||||
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
|
||||
const myId = socket?.myUserId;
|
||||
|
||||
// REST poll — the fallback whenever the live socket isn't connected.
|
||||
const refetchRef = useRef(q.refetch);
|
||||
refetchRef.current = q.refetch;
|
||||
useEffect(() => {
|
||||
if (socketReady) return;
|
||||
const t = setInterval(() => refetchRef.current(), POLL_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [socketReady, threadId]);
|
||||
|
||||
// Socket (primary): load history + subscribe to live messages, typing, receipts, reactions.
|
||||
useEffect(() => {
|
||||
if (!socket || !socketReady) return;
|
||||
let alive = true;
|
||||
setSocketMsgs([]); setSeenIds(new Set()); setTyping({});
|
||||
void socket.openThread(threadId).then((hist) => { if (alive) setSocketMsgs(hist); }).catch(() => {});
|
||||
|
||||
const offMsg = socket.subscribe(threadId, (m) =>
|
||||
setSocketMsgs((l) => (l.some((x) => x.id === m.id) ? l : [...l, m])),
|
||||
);
|
||||
const offTyping = socket.onTyping(threadId, (userId) =>
|
||||
setTyping((t) => ({ ...t, [userId]: Date.now() + TYPING_TTL_MS })),
|
||||
);
|
||||
// Receipts are a global stream (no threadId). Count only reads by the OTHER side; seenMine then
|
||||
// narrows to my messages in this thread.
|
||||
const offReceipt = socket.onReceipt((e) => {
|
||||
if (e.actorId === myActorIdRef.current) return;
|
||||
setSeenIds((s) => (s.has(e.interactionId) ? s : new Set(s).add(e.interactionId)));
|
||||
});
|
||||
const offAnn = socket.onAnnotation(threadId, (e) =>
|
||||
setSocketMsgs((l) => l.map((m) => (m.id === e.interactionId ? { ...m, reactions: applyAnnotation(m.reactions, e, myId) } : m))),
|
||||
);
|
||||
return () => { alive = false; offMsg(); offTyping(); offReceipt(); offAnn(); };
|
||||
}, [socket, socketReady, threadId, myId]);
|
||||
|
||||
// Learn my own actor id from a message I sent, so receipts from OTHER actors read as "seen".
|
||||
useEffect(() => {
|
||||
const mine = socketMsgs.find((m) => m.mine && m.actorId);
|
||||
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
|
||||
}, [socketMsgs, myActorId]);
|
||||
|
||||
// Tell the server I've read the latest message (drives the other side's "seen" tick).
|
||||
useEffect(() => {
|
||||
if (!socket || !socketReady || socketMsgs.length === 0) return;
|
||||
socket.markRead(threadId, socketMsgs[socketMsgs.length - 1].id);
|
||||
}, [socket, socketReady, threadId, socketMsgs]);
|
||||
|
||||
// Expire stale typing entries.
|
||||
const typingUserIds = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return Object.entries(typing).filter(([, exp]) => exp > now).map(([u]) => u);
|
||||
}, [typing]);
|
||||
useEffect(() => {
|
||||
if (typingUserIds.length === 0) return;
|
||||
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [typingUserIds.length, typing]);
|
||||
|
||||
const restMsgs: UiMessage[] = useMemo(
|
||||
() => (q.data ?? []).map((m) => ({
|
||||
id: m.interactionId, actorId: m.actorId, senderId: null, text: m.text ?? "", at: m.occurredAt,
|
||||
mine: !!myActorId && m.actorId === myActorId, reactions: [],
|
||||
})),
|
||||
[q.data, myActorId],
|
||||
);
|
||||
|
||||
const messages = socketReady ? socketMsgs : restMsgs;
|
||||
|
||||
// My messages the other side has read (receipts carry the other actor's id).
|
||||
const seenMine = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
|
||||
return out;
|
||||
}, [seenIds, messages]);
|
||||
|
||||
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => {
|
||||
if (socket && socketReady) {
|
||||
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
|
||||
} else {
|
||||
// REST fallback carries the attachment ref too; a socket reconnect will replace with the live copy.
|
||||
const m = (await sdk.command("crm.messenger.send", { threadId, content, ...(opts?.attachment ? { attachment: opts.attachment } : {}) })) as MessageDTO;
|
||||
if (m.actorId) setMyActorId(m.actorId);
|
||||
q.refetch();
|
||||
}
|
||||
}, [socket, socketReady, threadId, sdk, q]);
|
||||
|
||||
const react = useCallback((interactionId: string, emoji: string) => {
|
||||
if (socket && socketReady) socket.react(threadId, interactionId, emoji);
|
||||
}, [socket, socketReady, threadId]);
|
||||
|
||||
return {
|
||||
loading: q.loading && !socketReady, error: q.error?.message ?? null,
|
||||
messages, send, react, typingUserIds, seenIds: seenMine, refetch: q.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
function useLiveGroupSettings(threadId: string): GroupSettingsData {
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const q = useQuery<UiMember[]>("crm.messenger.members", { threadId });
|
||||
const members = useMemo(() => q.data ?? [], [q.data]);
|
||||
const isAdmin = useMemo(() => members.some((m) => m.userId === user?.id && m.role === "ADMIN"), [members, user?.id]);
|
||||
|
||||
const rename = useCallback(async (subject: string) => {
|
||||
await sdk.command("crm.messenger.group.rename", { threadId, subject });
|
||||
q.refetch();
|
||||
}, [sdk, threadId, q]);
|
||||
const addMember = useCallback(async (userId: string) => {
|
||||
await sdk.command("crm.messenger.participant.add", { threadId, userId });
|
||||
q.refetch();
|
||||
}, [sdk, threadId, q]);
|
||||
const removeMember = useCallback(async (userId: string) => {
|
||||
await sdk.command("crm.messenger.participant.remove", { threadId, userId });
|
||||
q.refetch();
|
||||
}, [sdk, threadId, q]);
|
||||
|
||||
return { loading: q.loading, error: q.error?.message ?? null, members, isAdmin, rename, addMember, removeMember, refetch: q.refetch };
|
||||
}
|
||||
|
||||
function shape(
|
||||
c: ConversationDTO,
|
||||
nameOf: (id: string) => string,
|
||||
myId: string | undefined,
|
||||
overlay?: { lastMessage: string; lastAt: string },
|
||||
): UiConversation {
|
||||
// A DM's title is the OTHER person — never yourself, and never the raw unknown-id fallback for both.
|
||||
const others = myId ? c.participants.filter((p) => p !== myId) : c.participants;
|
||||
const title = c.subject?.trim()
|
||||
|| (c.membership === "group"
|
||||
? `Group · ${c.participants.length}`
|
||||
: (others.map(nameOf).join(", ") || nameOf(c.participants[0] ?? "") || "Conversation"));
|
||||
const lastMessage = overlay?.lastMessage ?? c.lastMessage;
|
||||
const lastAt = overlay?.lastAt ?? c.lastAt;
|
||||
return {
|
||||
threadId: c.threadId, title, subject: c.subject, membership: c.membership,
|
||||
participants: c.participants, unread: c.unread,
|
||||
...(lastMessage ? { lastMessage } : {}), ...(lastAt ? { lastAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Mock implementation (no Shell configured — the demo keeps working) */
|
||||
/* ======================================================================== */
|
||||
|
||||
const MOCK_PEOPLE: UiPerson[] = [
|
||||
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
|
||||
{ id: "pp_dan", name: "Dan Whitaker", kind: "staff" },
|
||||
{ id: "pp_priya", name: "Priya Nair", kind: "staff" },
|
||||
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
|
||||
{ id: "cust_globex", name: "Globex Homes (Client)", kind: "customer" },
|
||||
];
|
||||
|
||||
interface MockThread { threadId: string; membership: Membership; subject: string | null; participants: string[]; messages: UiMessage[] }
|
||||
const now = () => new Date().toISOString();
|
||||
let MOCK_SEQ = 100;
|
||||
|
||||
// A tiny module-level store both mock hooks share, with a subscribe-on-change so the
|
||||
// conversation list and the open thread stay in sync (no globalThis, no render writes).
|
||||
const MOCK_STORE = new Map<string, MockThread>([
|
||||
["th_mock_1", { threadId: "th_mock_1", membership: "dm", subject: null, participants: ["me", "pp_sofia"],
|
||||
messages: [{ id: "m1", actorId: "pp_sofia", text: "Can you review the Henderson estimate?", at: now(), mine: false, reactions: [] }] }],
|
||||
["th_mock_2", { threadId: "th_mock_2", membership: "group", subject: "Storm response — East side", participants: ["me", "pp_dan", "pp_priya"],
|
||||
messages: [{ id: "m2", actorId: "pp_dan", text: "Crew is rolling out at 7.", at: now(), mine: false, reactions: [] }] }],
|
||||
]);
|
||||
const mockListeners = new Set<() => void>();
|
||||
const notifyMock = () => mockListeners.forEach((l) => l());
|
||||
function useMockSubscription(): void {
|
||||
const [, setV] = useState(0);
|
||||
useEffect(() => {
|
||||
const l = () => setV((n) => n + 1);
|
||||
mockListeners.add(l);
|
||||
return () => { mockListeners.delete(l); };
|
||||
}, []);
|
||||
}
|
||||
|
||||
function useMockMessenger(): MessengerData {
|
||||
useMockSubscription();
|
||||
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
|
||||
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
|
||||
|
||||
const conversations: UiConversation[] = [...MOCK_STORE.values()].map((t) => {
|
||||
const last = t.messages[t.messages.length - 1];
|
||||
return {
|
||||
threadId: t.threadId,
|
||||
title: t.subject || t.participants.filter((p) => p !== "me").map(nameOf).join(", ") || "Conversation",
|
||||
subject: t.subject, membership: t.membership, participants: t.participants, unread: 0,
|
||||
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
|
||||
const membership = opts?.membership ?? (participantIds.length === 1 ? "dm" : "group");
|
||||
const threadId = `th_mock_${MOCK_SEQ++}`;
|
||||
MOCK_STORE.set(threadId, { threadId, membership, subject: opts?.subject ?? null, participants: ["me", ...participantIds], messages: [] });
|
||||
notifyMock();
|
||||
return threadId;
|
||||
}, []);
|
||||
|
||||
return { live: false, loading: false, error: null, directory: MOCK_PEOPLE, conversations, nameOf, openConversation, refetch: () => {} };
|
||||
}
|
||||
|
||||
function useMockThread(threadId: string): ThreadData {
|
||||
useMockSubscription();
|
||||
const thread = MOCK_STORE.get(threadId);
|
||||
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string }) => {
|
||||
const t = MOCK_STORE.get(threadId);
|
||||
if (t) {
|
||||
t.messages = [...t.messages, {
|
||||
id: `m_${MOCK_SEQ++}`, actorId: "me", text: content, at: now(), mine: true, reactions: [],
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
}];
|
||||
notifyMock();
|
||||
}
|
||||
}, [threadId]);
|
||||
const react = useCallback((interactionId: string, emoji: string) => {
|
||||
const t = MOCK_STORE.get(threadId);
|
||||
if (!t) return;
|
||||
t.messages = t.messages.map((m) => {
|
||||
if (m.id !== interactionId) return m;
|
||||
const has = (m.reactions ?? []).find((r) => r.emoji === emoji);
|
||||
const reactions = has
|
||||
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
|
||||
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
|
||||
return { ...m, reactions };
|
||||
});
|
||||
notifyMock();
|
||||
}, [threadId]);
|
||||
return {
|
||||
loading: false, error: null, messages: thread?.messages ?? [], send, react,
|
||||
typingUserIds: [], seenIds: new Set(), refetch: notifyMock,
|
||||
};
|
||||
}
|
||||
|
||||
function useMockGroupSettings(threadId: string): GroupSettingsData {
|
||||
useMockSubscription();
|
||||
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
|
||||
const t = MOCK_STORE.get(threadId);
|
||||
const members: UiMember[] = (t?.participants ?? []).map((id) => ({
|
||||
userId: id,
|
||||
displayName: id === "me" ? "You" : (nameById[id] ?? `User ${shortId(id)}`),
|
||||
role: id === "me" ? "ADMIN" : "MEMBER",
|
||||
}));
|
||||
const rename = useCallback(async (subject: string) => {
|
||||
const th = MOCK_STORE.get(threadId);
|
||||
if (th) { th.subject = subject; notifyMock(); }
|
||||
}, [threadId]);
|
||||
const addMember = useCallback(async (userId: string) => {
|
||||
const th = MOCK_STORE.get(threadId);
|
||||
if (th && !th.participants.includes(userId)) { th.participants = [...th.participants, userId]; notifyMock(); }
|
||||
}, [threadId]);
|
||||
const removeMember = useCallback(async (userId: string) => {
|
||||
const th = MOCK_STORE.get(threadId);
|
||||
if (th) { th.participants = th.participants.filter((p) => p !== userId); notifyMock(); }
|
||||
}, [threadId]);
|
||||
return { loading: false, error: null, members, isAdmin: true, rename, addMember, removeMember, refetch: notifyMock };
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// v2 live stream: one IIOS MessageSocket for the whole Messenger panel, using the SDK
|
||||
// (@insignia/iios-kernel-client) — not raw socket.io. The delegated realtime token comes from
|
||||
// the be-crm data door (crm.messenger.realtime). Threads subscribe through a context; the socket
|
||||
// re-opens every joined thread on reconnect (handled inside the SDK). In mock mode this is a no-op
|
||||
// passthrough and the thread hook falls back to the REST poll.
|
||||
//
|
||||
// Beyond plain messages, the kernel exposes typing, read receipts, and reactions (generic
|
||||
// annotations). This provider fans each server event out to per-thread listeners so the UI can
|
||||
// render typing indicators, "seen" ticks, and emoji reactions live.
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { MessageSocket, type Message, type AnnotationEvent } from "@insignia/iios-kernel-client";
|
||||
import { useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
import { toReactions, type UiMessage } from "./messenger-api";
|
||||
|
||||
interface RealtimeDTO { url: string; audience: string; token?: string }
|
||||
|
||||
export interface ReceiptHit { interactionId: string; actorId: string }
|
||||
|
||||
export interface MessengerSocket {
|
||||
ready: boolean;
|
||||
myUserId?: string;
|
||||
openThread: (threadId: string) => Promise<UiMessage[]>;
|
||||
send: (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => Promise<void>;
|
||||
subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void;
|
||||
/** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */
|
||||
onAnyMessage: (cb: (threadId: string, m: UiMessage) => void) => () => void;
|
||||
sendTyping: (threadId: string) => void;
|
||||
onTyping: (threadId: string, cb: (userId: string) => void) => () => void;
|
||||
markRead: (threadId: string, interactionId: string) => void;
|
||||
/** The kernel's receipt event carries no threadId, so this is a global stream; the thread hook
|
||||
* filters to receipts for its own (mine) messages. */
|
||||
onReceipt: (cb: (e: ReceiptHit) => void) => () => void;
|
||||
react: (threadId: string, interactionId: string, emoji: string) => void;
|
||||
onAnnotation: (threadId: string, cb: (e: AnnotationEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<MessengerSocket | null>(null);
|
||||
export function useMessengerSocket(): MessengerSocket | null { return useContext(Ctx); }
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
const toUi = (m: Message, myUserId?: string): UiMessage => ({
|
||||
id: m.id, actorId: m.senderActorId ?? null, senderId: m.senderId ?? null, text: m.content ?? "", at: m.createdAt,
|
||||
mine: !!myUserId && m.senderId === myUserId,
|
||||
...(m.parentInteractionId ? { parentInteractionId: m.parentInteractionId } : {}),
|
||||
...(m.attachment ? { attachment: { contentRef: m.attachment.contentRef, mimeType: m.attachment.mimeType, sizeBytes: m.attachment.sizeBytes } } : {}),
|
||||
reactions: toReactions(m.annotations, myUserId),
|
||||
});
|
||||
|
||||
export function MessengerSocketProvider({ children }: { children: ReactNode }) {
|
||||
// SHELL is a build-time constant, so the branch is stable across renders (Rules-of-Hooks safe).
|
||||
if (!SHELL) return <>{children}</>;
|
||||
return <LiveSocketProvider>{children}</LiveSocketProvider>;
|
||||
}
|
||||
|
||||
// A tiny per-thread listener registry, reused for messages / typing / receipts / annotations.
|
||||
function makeRegistry<T>() {
|
||||
const map = new Map<string, Set<(v: T) => void>>();
|
||||
const add = (key: string, cb: (v: T) => void) => {
|
||||
if (!map.has(key)) map.set(key, new Set());
|
||||
map.get(key)!.add(cb);
|
||||
return () => { map.get(key)?.delete(cb); };
|
||||
};
|
||||
const emit = (key: string, v: T) => map.get(key)?.forEach((cb) => cb(v));
|
||||
return { add, emit };
|
||||
}
|
||||
|
||||
function LiveSocketProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
|
||||
const [ready, setReady] = useState(false);
|
||||
const socketRef = useRef<MessageSocket | null>(null);
|
||||
const myRef = useRef<string | undefined>(user?.id);
|
||||
myRef.current = user?.id;
|
||||
|
||||
// One registry per event kind, keyed by threadId (plus a global message fan-out).
|
||||
const msgReg = useRef(makeRegistry<UiMessage>()).current;
|
||||
const anyMsg = useRef(new Set<(threadId: string, m: UiMessage) => void>()).current;
|
||||
const typingReg = useRef(makeRegistry<string>()).current;
|
||||
const receiptSet = useRef(new Set<(e: ReceiptHit) => void>()).current;
|
||||
const annReg = useRef(makeRegistry<AnnotationEvent>()).current;
|
||||
|
||||
const url = rt.data?.url;
|
||||
const token = rt.data?.token;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
const socket = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
|
||||
socketRef.current = socket;
|
||||
const offConnected = socket.onConnected(() => setReady(true));
|
||||
const offMessage = socket.on("message", (m) => {
|
||||
const ui = toUi(m, myRef.current);
|
||||
msgReg.emit(m.threadId, ui);
|
||||
anyMsg.forEach((cb) => cb(m.threadId, ui));
|
||||
});
|
||||
const offTyping = socket.on("typing", (e) => { if (e.userId !== myRef.current) typingReg.emit(e.threadId, e.userId); });
|
||||
const offReceipt = socket.on("receipt", (e) => receiptSet.forEach((cb) => cb({ interactionId: e.interactionId, actorId: e.actorId })));
|
||||
const offAnn = socket.on("annotation", (e) => annReg.emit(e.threadId, e));
|
||||
socket.connect();
|
||||
return () => {
|
||||
offConnected(); offMessage(); offTyping(); offReceipt(); offAnn();
|
||||
socket.disconnect(); socketRef.current = null; setReady(false);
|
||||
};
|
||||
}, [url, token, msgReg, anyMsg, typingReg, receiptSet, annReg]);
|
||||
|
||||
const openThread = useCallback(async (threadId: string): Promise<UiMessage[]> => {
|
||||
const s = socketRef.current;
|
||||
if (!s) return [];
|
||||
const res = await s.openThread(threadId);
|
||||
return res.history.map((m) => toUi(m, myRef.current));
|
||||
}, []);
|
||||
|
||||
const send = useCallback(async (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => {
|
||||
const s = socketRef.current;
|
||||
if (!s) throw new Error("Not connected");
|
||||
const sendOpts = {
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
||||
};
|
||||
await s.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => msgReg.add(threadId, cb), [msgReg]);
|
||||
const onAnyMessage = useCallback((cb: (threadId: string, m: UiMessage) => void) => {
|
||||
anyMsg.add(cb); return () => { anyMsg.delete(cb); };
|
||||
}, [anyMsg]);
|
||||
const onTyping = useCallback((threadId: string, cb: (userId: string) => void) => typingReg.add(threadId, cb), [typingReg]);
|
||||
const onReceipt = useCallback((cb: (e: ReceiptHit) => void) => {
|
||||
receiptSet.add(cb); return () => { receiptSet.delete(cb); };
|
||||
}, [receiptSet]);
|
||||
const onAnnotation = useCallback((threadId: string, cb: (e: AnnotationEvent) => void) => annReg.add(threadId, cb), [annReg]);
|
||||
|
||||
const sendTyping = useCallback((threadId: string) => socketRef.current?.typing(threadId), []);
|
||||
const markRead = useCallback((threadId: string, interactionId: string) => { void socketRef.current?.markRead(threadId, interactionId); }, []);
|
||||
const react = useCallback((threadId: string, interactionId: string, emoji: string) => { void socketRef.current?.react(threadId, interactionId, emoji); }, []);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{
|
||||
ready, myUserId: user?.id, openThread, send, subscribe, onAnyMessage,
|
||||
sendTyping, onTyping, markRead, onReceipt, react, onAnnotation,
|
||||
}}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
// Offline notifications data layer (Web Push). Registers the service worker, subscribes the browser
|
||||
// with IIOS's VAPID key, and hands the subscription to the be-crm door so IIOS can reach this user
|
||||
// while no CRM tab is open. Push needs a real backend (VAPID key + delivery), so it is only offered
|
||||
// when the Shell is configured (live); demo mode reports it unsupported.
|
||||
//
|
||||
// Live contract (be-crm data door → IIOS /v1/notifications/*):
|
||||
// query crm.messenger.push.vapidKey {} -> { key } ('' = push disabled server-side)
|
||||
// cmd crm.messenger.push.subscribe { endpoint, keys, userAgent } -> { ok }
|
||||
// cmd crm.messenger.push.unsubscribe { endpoint } -> { ok }
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
const SW_URL = "/push-sw.js";
|
||||
|
||||
export type PushPermission = "default" | "granted" | "denied";
|
||||
|
||||
export interface PushState {
|
||||
/** Browser can do Web Push AND we have a live backend to deliver it. */
|
||||
supported: boolean;
|
||||
/** OS/browser permission for notifications. */
|
||||
permission: PushPermission;
|
||||
/** This browser currently has an active push subscription registered with the backend. */
|
||||
subscribed: boolean;
|
||||
/** A subscribe/unsubscribe round-trip is in flight. */
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** VAPID keys travel as URL-safe base64; PushManager wants raw bytes. */
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(normalized);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function browserSupportsPush(): boolean {
|
||||
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
/** Serialize a PushSubscription into the door's { endpoint, keys } shape. */
|
||||
function toSubscribeBody(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string }; userAgent: string } {
|
||||
const json = sub.toJSON();
|
||||
return {
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: json.keys?.p256dh ?? "", auth: json.keys?.auth ?? "" },
|
||||
userAgent: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 400) : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function usePushNotifications(): PushState {
|
||||
const { sdk } = useAppShell();
|
||||
const [supported] = useState<boolean>(() => SHELL && browserSupportsPush());
|
||||
const [permission, setPermission] = useState<PushPermission>("default");
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reflect the current OS permission + whether a subscription already exists (e.g. across reloads).
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
setPermission(Notification.permission as PushPermission);
|
||||
let cancelled = false;
|
||||
navigator.serviceWorker.ready
|
||||
.then((reg) => reg.pushManager.getSubscription())
|
||||
.then((sub) => {
|
||||
if (!cancelled) setSubscribed(Boolean(sub));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [supported]);
|
||||
|
||||
const enable = useCallback(async () => {
|
||||
if (!supported) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm as PushPermission);
|
||||
if (perm !== "granted") throw new Error("Notifications permission was not granted.");
|
||||
|
||||
const reg = await navigator.serviceWorker.register(SW_URL);
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const { key } = await sdk.query<{ key: string }>("crm.messenger.push.vapidKey", {});
|
||||
if (!key) throw new Error("Push is not enabled on the server (no VAPID key).");
|
||||
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
const sub =
|
||||
existing ??
|
||||
(await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
// Cast: this lib's BufferSource type pins ArrayBuffer, but a plain Uint8Array is valid here.
|
||||
applicationServerKey: urlBase64ToUint8Array(key) as BufferSource,
|
||||
}));
|
||||
|
||||
await sdk.command("crm.messenger.push.subscribe", toSubscribeBody(sub));
|
||||
setSubscribed(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not enable notifications.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [supported, sdk]);
|
||||
|
||||
const disable = useCallback(async () => {
|
||||
if (!supported) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
// Tell the backend first (still has the endpoint), then drop the local subscription.
|
||||
await sdk.command("crm.messenger.push.unsubscribe", { endpoint: sub.endpoint }).catch(() => {});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
setSubscribed(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not disable notifications.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [supported, sdk]);
|
||||
|
||||
return { supported, permission, subscribed, busy, error, enable, disable };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
// One shared IIOS message socket for the whole dashboard, so the messenger tab AND the app-wide
|
||||
// notification center use a single connection (not one each). Opened at the dashboard level and
|
||||
// kept alive across tab switches; the messenger tab reuses it via useRealtime().
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { MessageSocket } from "@insignia/iios-kernel-client";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
const RealtimeContext = createContext<MessageSocket | null>(null);
|
||||
interface RealtimeDTO { url: string; audience: string; token?: string }
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
function LiveRealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
|
||||
const [socket, setSocket] = useState<MessageSocket | null>(null);
|
||||
const url = rt.data?.url;
|
||||
const token = rt.data?.token;
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
|
||||
s.connect();
|
||||
setSocket(s);
|
||||
return () => {
|
||||
s.disconnect();
|
||||
setSocket(null);
|
||||
};
|
||||
}, [url, token]);
|
||||
return <RealtimeContext.Provider value={socket}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
// SHELL is a build-time constant, so the same branch runs every render (Rules-of-Hooks safe).
|
||||
if (!SHELL) return <>{children}</>;
|
||||
return <LiveRealtimeProvider>{children}</LiveRealtimeProvider>;
|
||||
}
|
||||
|
||||
/** The shared socket, or null (demo mode / not yet connected). */
|
||||
export function useRealtime(): MessageSocket | null {
|
||||
return useContext(RealtimeContext);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
// Global conversation search. Live path calls the be-crm data door (crm.search → IIOS Meilisearch,
|
||||
// permission-scoped there); demo path filters a small in-memory set. Search is imperative (the query
|
||||
// changes on every keystroke), so it uses sdk.query directly rather than the cached useQuery hook.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
export interface SearchResult {
|
||||
interactionId: string;
|
||||
threadId: string;
|
||||
surface: "messenger" | "inbox";
|
||||
title: string;
|
||||
/** Snippet with <em>…</em> around the matched terms. */
|
||||
snippet: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export type SearchFn = (query: string) => Promise<SearchResult[]>;
|
||||
|
||||
const MOCK: SearchResult[] = [
|
||||
{ interactionId: "s1", threadId: "th_mock_1", surface: "messenger", title: "Sofia Ramirez", snippet: "Can you confirm the <em>Henderson</em> scope?", at: Date.now() },
|
||||
{ interactionId: "s2", threadId: "th_mock_2", surface: "messenger", title: "Storm response — East side", snippet: "Crew is <em>rolling</em> out at 7", at: Date.now() },
|
||||
{ interactionId: "s3", threadId: "mt_invoice", surface: "inbox", title: "Invoice #1042 — Acme Roofing", snippet: "Attached is <em>invoice</em> #1042 for the East-side job", at: Date.now() },
|
||||
];
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
function useLiveSearch(): SearchFn {
|
||||
const { sdk } = useAppShell();
|
||||
return useCallback(async (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
return sdk.query<SearchResult[]>("crm.search", { query: q, limit: 20 });
|
||||
}, [sdk]);
|
||||
}
|
||||
|
||||
function useMockSearch(): SearchFn {
|
||||
return useCallback(async (query: string) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return MOCK.filter((m) => (m.title + " " + m.snippet).toLowerCase().includes(q));
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useGlobalSearch(): SearchFn {
|
||||
return SHELL ? useLiveSearch() : useMockSearch();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tiny in-process fixed-window rate limiter for the Smart Gallery AI routes.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* SCOPE AND LIMITATIONS — READ BEFORE RELYING ON THIS
|
||||
* ---------------------------------------------------------------------------
|
||||
* State lives in a plain `Map` in THIS process's memory. That means:
|
||||
*
|
||||
* - PER-INSTANCE, NOT GLOBAL. With N app instances behind a load balancer a
|
||||
* caller gets up to N x the configured budget. On serverless platforms each
|
||||
* cold start begins with an empty map, so the effective limit is weaker
|
||||
* still.
|
||||
* - NOT DURABLE. A restart or redeploy clears every counter.
|
||||
* - FIXED WINDOW, NOT SLIDING. A caller can burst `max` at the very end of one
|
||||
* window and `max` again at the start of the next — up to 2x `max` across a
|
||||
* window boundary. Acceptable here; the goal is to bound runaway cost, not
|
||||
* to meter precisely.
|
||||
*
|
||||
* It exists because the routes it guards spend real money on GPU inference and
|
||||
* shipping them with NO limit at all is worse than shipping an imperfect one.
|
||||
*
|
||||
* REPLACE WITH REDIS (or the platform's rate limiter) BEFORE RUNNING MORE THAN
|
||||
* ONE INSTANCE. The `limit()` signature is deliberately narrow so a Redis-backed
|
||||
* implementation can drop straight in — the only change needed is making it
|
||||
* async at the call sites.
|
||||
*
|
||||
* This is a throttle, not an authorization check. See src/lib/server/session.ts.
|
||||
*/
|
||||
|
||||
interface Window {
|
||||
/** Requests counted so far in the current window. */
|
||||
count: number;
|
||||
/** Epoch ms at which the current window ends. */
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const windows = new Map<string, Window>();
|
||||
|
||||
/** Drop expired entries so the map cannot grow without bound. */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
let lastSweep = 0;
|
||||
|
||||
function sweep(now: number): void {
|
||||
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
|
||||
lastSweep = now;
|
||||
for (const [key, w] of windows) {
|
||||
if (w.resetAt <= now) windows.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LimitResult {
|
||||
ok: boolean;
|
||||
/** Seconds until the window resets. Send as `Retry-After` when `ok` is false. */
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one request against `key` and report whether it is allowed.
|
||||
*
|
||||
* @param key Caller identity — use `rateLimitKey()` from ./session.
|
||||
* @param max Requests allowed per window.
|
||||
* @param windowMs Window length in ms.
|
||||
*/
|
||||
export function limit(key: string, max: number, windowMs: number): LimitResult {
|
||||
const now = Date.now();
|
||||
sweep(now);
|
||||
|
||||
const existing = windows.get(key);
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
windows.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= max) {
|
||||
return { ok: false, retryAfter: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
/** Test/maintenance helper — clears all counters. */
|
||||
export function resetAllLimits(): void {
|
||||
windows.clear();
|
||||
lastSweep = 0;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* base64 / data-URI helpers shared by the RunPod endpoint wrappers.
|
||||
* Different endpoints name their image field differently and some prefix a
|
||||
* `data:` URI — these helpers normalize both.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/base64.ts`.
|
||||
* The `pickOutputImage` normalization is intentionally identical so both apps
|
||||
* tolerate the same set of endpoint response shapes.
|
||||
*
|
||||
* SERVER-ONLY.
|
||||
*/
|
||||
|
||||
/** "data:image/png;base64,XXXX" -> "XXXX" (leaves a bare base64 string untouched). */
|
||||
export function stripDataUri(s: string): string {
|
||||
if (!s.startsWith("data:")) return s;
|
||||
const i = s.indexOf(",");
|
||||
return i === -1 ? s : s.slice(i + 1);
|
||||
}
|
||||
|
||||
/** Wrap a bare base64 string in a data: URI. */
|
||||
export function toDataUri(b64: string, mime: string): string {
|
||||
return `data:${mime};base64,${stripDataUri(b64)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the first image-like base64 string out of a RunPod endpoint's `output`,
|
||||
* regardless of which field name it used. Handles the common shapes:
|
||||
* "iVBOR..." (raw string)
|
||||
* { image: "..." } / { image_png } / { image_base64 }
|
||||
* { images: ["..."] } (array)
|
||||
* { output: { image: "..." } } (nested)
|
||||
*/
|
||||
export function pickOutputImage(output: unknown): string {
|
||||
const s = findImageString(output, false);
|
||||
if (!s) throw new Error("RunPod output did not contain an image.");
|
||||
return stripDataUri(s);
|
||||
}
|
||||
|
||||
/** Keys whose name implies the value IS the image — any non-empty string is accepted. */
|
||||
const IMAGE_KEYS = ["image_png", "image", "image_base64", "images"] as const;
|
||||
/** Generic wrapper keys — a string here must actually look like image data. */
|
||||
const CONTAINER_KEYS = ["output", "result", "data"] as const;
|
||||
|
||||
/** A base64 image payload is long; a status/id string ("success", "job-abc") is short. */
|
||||
function looksLikeImageData(s: string): boolean {
|
||||
if (s.startsWith("data:image/")) return true;
|
||||
return s.length >= 256 && /^[A-Za-z0-9+/=\s]+$/.test(s.slice(0, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* `strict` is true when we descended through a generic wrapper key (output/result/
|
||||
* data), where a bare string could be a status/id rather than an image — so it must
|
||||
* pass `looksLikeImageData`. Under an explicit image key (or at top level) any
|
||||
* non-empty string is taken as the image.
|
||||
*/
|
||||
function findImageString(value: unknown, strict: boolean, depth = 0): string | undefined {
|
||||
if (depth > 5) return undefined;
|
||||
if (typeof value === "string") {
|
||||
if (!value) return undefined;
|
||||
return !strict || looksLikeImageData(value) ? value : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const el of value) {
|
||||
const s = findImageString(el, strict, depth + 1);
|
||||
if (s) return s;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const o = value as Record<string, unknown>;
|
||||
for (const key of IMAGE_KEYS) {
|
||||
if (key in o) {
|
||||
const s = findImageString(o[key], false, depth + 1);
|
||||
if (s) return s;
|
||||
}
|
||||
}
|
||||
for (const key of CONTAINER_KEYS) {
|
||||
if (key in o) {
|
||||
const s = findImageString(o[key], true, depth + 1);
|
||||
if (s) return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Low-level RunPod transport. Every model runs as its own RunPod endpoint; this
|
||||
* handles the common request envelope, bearer auth, and both response modes:
|
||||
*
|
||||
* - `/runsync` (preferred): the job runs synchronously and the body already
|
||||
* contains `output` (or IS the output for a custom handler).
|
||||
* - `/run`: returns `{ id, status }`; we poll `/status/{id}` until the job
|
||||
* reaches a terminal state or the time budget (kept under the 60s serverless
|
||||
* function cap) is exhausted.
|
||||
*
|
||||
* RUNPOD_API_KEY is read here so callers never handle the secret directly, and
|
||||
* it is NEVER echoed into an error message. Upstream error bodies are truncated
|
||||
* to 160 chars before being surfaced.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/client.ts` (same 55s
|
||||
* budget, same polling, same RunpodError.status mapping).
|
||||
*
|
||||
* SERVER-ONLY.
|
||||
*/
|
||||
|
||||
export class RunpodError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 502) {
|
||||
super(message);
|
||||
this.name = "RunpodError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunpodCallOpts {
|
||||
/** Human label used in error messages, e.g. "sd-inpaint". */
|
||||
name: string;
|
||||
/** Full endpoint URL from the per-model env var (…/runsync or …/run). */
|
||||
url: string;
|
||||
input: Record<string, unknown>;
|
||||
/** Total budget for the whole call incl. polling. Default 55s (< the 60s cap). */
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
interface RunpodEnvelope {
|
||||
id?: string;
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export async function runpodCall<TOut = unknown>(opts: RunpodCallOpts): Promise<TOut> {
|
||||
const { name, url, input } = opts;
|
||||
const timeoutMs = opts.timeoutMs ?? 55_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 1500;
|
||||
|
||||
const key = process.env.RUNPOD_API_KEY;
|
||||
if (!key) throw new RunpodError("RUNPOD_API_KEY is not set.", 500);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const authHeaders = { authorization: `Bearer ${key}` };
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authHeaders },
|
||||
body: JSON.stringify({ input }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new RunpodError(`Could not reach RunPod (${name}).`, 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Truncated on purpose — never surface a full upstream body to the client.
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||
throw new RunpodError(`RunPod ${name} error (${res.status}). ${detail}`.trim(), 502);
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => null)) as RunpodEnvelope | null;
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new RunpodError(`RunPod ${name} returned an invalid response.`, 502);
|
||||
}
|
||||
|
||||
// Terminal failure reported in a job envelope.
|
||||
if (data.status === "FAILED" || data.status === "CANCELLED") {
|
||||
throw new RunpodError(`RunPod ${name} job ${data.status.toLowerCase()}.`, 502);
|
||||
}
|
||||
// No job id → this is not an async envelope; the body itself is the output.
|
||||
// Covers custom /runsync handlers that return their result directly, even when
|
||||
// it carries a `status` field (e.g. "success").
|
||||
if (data.id === undefined) {
|
||||
return (data.output !== undefined ? data.output : data) as TOut;
|
||||
}
|
||||
// Job envelope that already carries a completed/inline output.
|
||||
if (data.output !== undefined && (data.status === undefined || data.status === "COMPLETED")) {
|
||||
return data.output as TOut;
|
||||
}
|
||||
|
||||
// Async: poll /status/{id} until COMPLETED / FAILED / the time budget runs out.
|
||||
// Each wait + fetch is clamped to the remaining budget so the whole call stays
|
||||
// under `timeoutMs` (kept below the 60s function cap).
|
||||
const statusUrl = url.replace(/\/run(sync)?(\/?)$/, `/status/${data.id}`);
|
||||
for (;;) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining < 500) break; // not enough budget for another round
|
||||
await sleep(Math.min(pollIntervalMs, remaining));
|
||||
const left = deadline - Date.now();
|
||||
if (left <= 0) break;
|
||||
let sres: Response;
|
||||
try {
|
||||
sres = await fetch(statusUrl, {
|
||||
headers: authHeaders,
|
||||
signal: AbortSignal.timeout(Math.min(10_000, Math.max(1000, left))),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
continue; // transient — keep polling until the deadline
|
||||
}
|
||||
if (!sres.ok) continue;
|
||||
const sdata = (await sres.json().catch(() => null)) as RunpodEnvelope | null;
|
||||
if (!sdata) continue;
|
||||
if (sdata.status === "COMPLETED") return sdata.output as TOut;
|
||||
if (sdata.status === "FAILED" || sdata.status === "CANCELLED") {
|
||||
throw new RunpodError(`RunPod ${name} job ${sdata.status.toLowerCase()}.`, 502);
|
||||
}
|
||||
}
|
||||
throw new RunpodError(
|
||||
`RunPod ${name} timed out (raise the endpoint's speed or use /runsync).`,
|
||||
504,
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Typed wrappers for each RunPod endpoint behind the Smart Gallery.
|
||||
* Every function reads its own `RUNPOD_*_URL` env var, sends the exact `input`
|
||||
* contract the model spec documents, and normalizes the response.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/endpoints.ts` —
|
||||
* identical `normalizeDetections` / `normalizeBox` logic and identical env var
|
||||
* names, so an endpoint deployed for the SDK demo works here unchanged.
|
||||
*
|
||||
* SERVER-ONLY. Missing/invalid URLs throw a RunpodError(500) so an unconfigured
|
||||
* op surfaces as a clear message rather than a crash.
|
||||
*/
|
||||
|
||||
import { stripDataUri, pickOutputImage } from "./base64";
|
||||
import { RunpodError, runpodCall } from "./client";
|
||||
import type {
|
||||
Img2ImgReq,
|
||||
InpaintReq,
|
||||
RunpodDetection,
|
||||
RunpodImageResult,
|
||||
TiltResult,
|
||||
TranscriptResult,
|
||||
} from "./types";
|
||||
|
||||
function envNum(v: string | undefined, fallback: number): number {
|
||||
// Treat a blank/whitespace env var as unset — Number('') is 0 (finite), which
|
||||
// would otherwise send e.g. strength:0 for `RUNPOD_SD_STRENGTH=`.
|
||||
if (v == null || v.trim() === "") return fallback;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function endpointUrl(envVar: string): string {
|
||||
const url = process.env[envVar];
|
||||
if (!url || !/^https?:\/\//i.test(url)) {
|
||||
throw new RunpodError(`${envVar} is not set (or is not an http(s) URL).`, 500);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Run an image-in/image-out endpoint and normalize the result to base64 PNG. */
|
||||
async function imageOp(
|
||||
name: string,
|
||||
envVar: string,
|
||||
input: Record<string, unknown>,
|
||||
): Promise<RunpodImageResult> {
|
||||
const output = await runpodCall({ name, url: endpointUrl(envVar), input });
|
||||
return { imageBase64: pickOutputImage(output), mimeType: "image/png" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** #6 Background removal (U²-Net via rembg). model: u2net | u2netp | u2net_human_seg */
|
||||
export function rpRemoveBackground(imageB64: string, model?: string): Promise<RunpodImageResult> {
|
||||
return imageOp("background-removal", "RUNPOD_BG_REMOVE_URL", {
|
||||
task: "remove-bg",
|
||||
image: imageB64,
|
||||
...(model ? { model_name: model } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** #7 Real-ESRGAN enhance/upscale (RealESRGAN_x4plus). The caller's scale (from the
|
||||
* op / restore pass) is authoritative — it is not overridden by any env default. */
|
||||
export function rpUpscale(
|
||||
imageB64: string,
|
||||
scale: 2 | 4,
|
||||
faceEnhance = false,
|
||||
): Promise<RunpodImageResult> {
|
||||
return imageOp("upscale", "RUNPOD_UPSCALE_URL", {
|
||||
task: "upscale",
|
||||
image: imageB64,
|
||||
scale,
|
||||
face_enhance: faceEnhance,
|
||||
});
|
||||
}
|
||||
|
||||
/** #8 DDColor B&W → colorize. */
|
||||
export function rpColorize(imageB64: string, inputSize?: number): Promise<RunpodImageResult> {
|
||||
return imageOp("colorize", "RUNPOD_COLORIZE_URL", {
|
||||
image: imageB64,
|
||||
...(inputSize ? { input_size: inputSize } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** #9 SD 3.5 masked inpainting (sky fix / eraser / fill). Also #11 outpaint (pre-padded). */
|
||||
export function rpInpaint(p: InpaintReq): Promise<RunpodImageResult> {
|
||||
const input: Record<string, unknown> = {
|
||||
task: "inpaint",
|
||||
image: p.imageB64,
|
||||
mask: p.maskB64,
|
||||
prompt: p.prompt,
|
||||
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.8),
|
||||
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
|
||||
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
|
||||
};
|
||||
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
|
||||
if (negative) input.negative_prompt = negative;
|
||||
if (p.seed != null) input.seed = p.seed;
|
||||
return imageOp("sd-inpaint", "RUNPOD_SD_INPAINT_URL", input);
|
||||
}
|
||||
|
||||
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
|
||||
export function rpImg2Img(p: Img2ImgReq): Promise<RunpodImageResult> {
|
||||
const input: Record<string, unknown> = {
|
||||
task: "img2img",
|
||||
image: p.imageB64,
|
||||
prompt: p.prompt,
|
||||
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.6),
|
||||
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
|
||||
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
|
||||
};
|
||||
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
|
||||
if (negative) input.negative_prompt = negative;
|
||||
if (p.seed != null) input.seed = p.seed;
|
||||
return imageOp("sd-img2img", "RUNPOD_SD_IMG2IMG_URL", input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1 YOLO detection → SDK DetectedObject shape (box as fractions 0..1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function rpDetect(
|
||||
imageB64: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<RunpodDetection[]> {
|
||||
const output = await runpodCall<unknown>({
|
||||
name: "yolo-detect",
|
||||
url: endpointUrl("RUNPOD_YOLO_URL"),
|
||||
input: { image: imageB64, task: "detect" },
|
||||
});
|
||||
return normalizeDetections(output, width, height);
|
||||
}
|
||||
|
||||
function extractDetectionArray(output: unknown): unknown[] {
|
||||
if (Array.isArray(output)) return output;
|
||||
if (output && typeof output === "object") {
|
||||
const o = output as Record<string, unknown>;
|
||||
for (const key of ["detections", "predictions", "objects", "results", "boxes"]) {
|
||||
if (Array.isArray(o[key])) return o[key] as unknown[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** First non-empty STRING among the args (numbers ignored — a numeric `class` is an index, not a name). */
|
||||
function firstLabel(...vals: unknown[]): string | undefined {
|
||||
for (const v of vals) {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeDetections(output: unknown, width: number, height: number): RunpodDetection[] {
|
||||
const out: RunpodDetection[] = [];
|
||||
for (const raw of extractDetectionArray(output)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const o = raw as Record<string, unknown>;
|
||||
// Prefer a human-readable name (ultralytics tojson puts the string in `name`
|
||||
// and a numeric index in `class`); fall back to class_<id>. Using firstLabel
|
||||
// (not `??`) also means an explicit empty-string label doesn't get kept + dropped.
|
||||
const classId = o.class_id ?? (typeof o.class === "number" ? o.class : undefined);
|
||||
const label = (
|
||||
firstLabel(o.label, o.name, o.class_name, typeof o.class === "string" ? o.class : undefined) ??
|
||||
(classId != null ? `class_${classId}` : "object")
|
||||
).toLowerCase();
|
||||
const confidence = Number(o.confidence ?? o.score ?? o.conf ?? 0) || 0;
|
||||
const box = normalizeBox(o, width, height);
|
||||
if (box && label) out.push({ label, confidence, box });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function asNum4(v: unknown): [number, number, number, number] | null {
|
||||
if (!Array.isArray(v) || v.length < 4) return null;
|
||||
const a = num(v[0]);
|
||||
const b = num(v[1]);
|
||||
const c = num(v[2]);
|
||||
const d = num(v[3]);
|
||||
return a === null || b === null || c === null || d === null ? null : [a, b, c, d];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a detection box to {x, y, width, height} as fractions 0..1 of the
|
||||
* image, from whatever shape the endpoint emits:
|
||||
* - `xyxy: [x1,y1,x2,y2]` (ultralytics) and generic `box: [...]` → corner form
|
||||
* - `xywh: [...]` and COCO `bbox: [x,y,w,h]` → x/y/width/height form
|
||||
* - object `{x1,y1,x2,y2}` / `{left,top,right,bottom}` (ultralytics tojson) → corners
|
||||
* - object `{x,y,width,height}` → x/y/width/height
|
||||
* Pixel values (any component > 1) are divided by the image dims; already-
|
||||
* normalized fractions pass through.
|
||||
*/
|
||||
function normalizeBox(
|
||||
o: Record<string, unknown>,
|
||||
width: number,
|
||||
height: number,
|
||||
): RunpodDetection["box"] | null {
|
||||
const W = width || 1;
|
||||
const H = height || 1;
|
||||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
const frac = (x: number, y: number, w: number, h: number): RunpodDetection["box"] => {
|
||||
if (Math.max(Math.abs(x), Math.abs(y), Math.abs(w), Math.abs(h)) > 1) {
|
||||
x /= W;
|
||||
y /= H;
|
||||
w /= W;
|
||||
h /= H;
|
||||
}
|
||||
return { x: clamp01(x), y: clamp01(y), width: clamp01(w), height: clamp01(h) };
|
||||
};
|
||||
const fromXyxy = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
frac(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// 1. Array boxes, interpreted by which key holds them.
|
||||
const xyxyArr = asNum4(o.xyxy);
|
||||
if (xyxyArr) return fromXyxy(xyxyArr[0], xyxyArr[1], xyxyArr[2], xyxyArr[3]);
|
||||
const xywhArr = asNum4(o.xywh) ?? asNum4(o.bbox); // COCO `bbox` is [x,y,w,h]
|
||||
if (xywhArr) return frac(xywhArr[0], xywhArr[1], xywhArr[2], xywhArr[3]);
|
||||
const boxArr = asNum4(o.box); // generic array box → assume corner form
|
||||
if (boxArr) return fromXyxy(boxArr[0], boxArr[1], boxArr[2], boxArr[3]);
|
||||
|
||||
// 2. Object boxes (either nested under `box` or directly on the detection).
|
||||
const src =
|
||||
o.box && typeof o.box === "object" && !Array.isArray(o.box)
|
||||
? (o.box as Record<string, unknown>)
|
||||
: o;
|
||||
const x1 = num(src.x1 ?? src.left);
|
||||
const y1 = num(src.y1 ?? src.top);
|
||||
const x2 = num(src.x2 ?? src.right);
|
||||
const y2 = num(src.y2 ?? src.bottom);
|
||||
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) return fromXyxy(x1, y1, x2, y2);
|
||||
|
||||
const x = num(src.x);
|
||||
const y = num(src.y);
|
||||
const w = num(src.width);
|
||||
const h = num(src.height);
|
||||
if (x !== null && y !== null && w !== null && h !== null) return frac(x, y, w, h);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio / calibration endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** #2 Camera tilt (DeepSingleImageCalibration). */
|
||||
export async function rpTilt(imageB64: string): Promise<TiltResult> {
|
||||
const o = await runpodCall<Record<string, unknown>>({
|
||||
name: "tilt",
|
||||
url: endpointUrl("RUNPOD_TILT_URL"),
|
||||
input: { image: imageB64 },
|
||||
});
|
||||
return {
|
||||
rollDegrees: Number(o.roll_degrees ?? 0) || 0,
|
||||
pitchDegrees: Number(o.pitch_degrees ?? 0) || 0,
|
||||
fovDegrees: Number(o.fov_degrees ?? 0) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** #3 Voice-to-text (Parakeet). Audio must be WAV 16kHz mono PCM16. */
|
||||
export async function rpTranscribe(
|
||||
audioB64: string,
|
||||
opts?: { language?: string; timestamps?: boolean; punctuation?: boolean },
|
||||
): Promise<TranscriptResult> {
|
||||
const o = await runpodCall<Record<string, unknown>>({
|
||||
name: "transcribe",
|
||||
url: endpointUrl("RUNPOD_STT_URL"),
|
||||
input: { audio: audioB64, task: "transcribe", ...(opts ?? {}) },
|
||||
});
|
||||
const rawSegments = Array.isArray(o.segments) ? o.segments : [];
|
||||
const segments = rawSegments.map((s) => {
|
||||
const seg = (s ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
text: String(seg.text ?? ""),
|
||||
startSec: Number(seg.start_sec ?? 0) || 0,
|
||||
endSec: Number(seg.end_sec ?? 0) || 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
transcript: String(o.transcript ?? ""),
|
||||
segments: segments.length ? segments : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** #12 Audio noise removal (RNNoise). Audio must be WAV 48kHz mono 16-bit PCM. */
|
||||
export async function rpDenoiseAudio(audioB64: string): Promise<{ audioB64: string }> {
|
||||
const o = await runpodCall<Record<string, unknown>>({
|
||||
name: "audio-denoise",
|
||||
url: endpointUrl("RUNPOD_AUDIO_DENOISE_URL"),
|
||||
input: { audio: audioB64, task: "denoise" },
|
||||
});
|
||||
const a = o.audio ?? o.output ?? "";
|
||||
return { audioB64: stripDataUri(String(a)) };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Request/response types for the RunPod endpoints behind the Smart Gallery.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/types.ts`.
|
||||
* Keep the two in sync — the RunPod endpoints are shared between the standalone
|
||||
* SDK demo and this CRM.
|
||||
*
|
||||
* SERVER-ONLY. Imported by src/app/api/gallery/ai/* route handlers, never by a
|
||||
* client component (which must not see a RunPod URL or key).
|
||||
*/
|
||||
|
||||
/** Normalized image result returned by every image endpoint (raw base64, no data: prefix). */
|
||||
export interface RunpodImageResult {
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/** #9 SD 3.5 masked inpainting (also #11 outpaint, pre-padded). white in mask = regenerate. */
|
||||
export interface InpaintReq {
|
||||
imageB64: string;
|
||||
maskB64: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
strength?: number;
|
||||
guidanceScale?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
|
||||
export interface Img2ImgReq {
|
||||
imageB64: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
strength?: number;
|
||||
guidanceScale?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1 YOLO construction-material classifier, normalized to the SDK's
|
||||
* `DetectedObject` shape (box as fractions 0..1 of the image).
|
||||
*/
|
||||
export interface RunpodDetection {
|
||||
label: string;
|
||||
confidence: number;
|
||||
box: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/** #2 DeepSingleImageCalibration — camera tilt. */
|
||||
export interface TiltResult {
|
||||
rollDegrees: number;
|
||||
pitchDegrees: number;
|
||||
fovDegrees: number;
|
||||
}
|
||||
|
||||
/** #3 Parakeet voice-to-text. */
|
||||
export interface TranscriptSegment {
|
||||
text: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
}
|
||||
export interface TranscriptResult {
|
||||
transcript: string;
|
||||
segments?: TranscriptSegment[];
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Session gate for the Smart Gallery AI routes.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* TRUST MODEL
|
||||
* ---------------------------------------------------------------------------
|
||||
* These routes proxy a paid, rate-limited GPU backend (RunPod) using a secret
|
||||
* held only on this server. An unauthenticated route is therefore not merely an
|
||||
* information-disclosure problem — it is a billable-resource problem: anyone who
|
||||
* can reach the URL can spend the operator's GPU budget, and can use the CRM as
|
||||
* an open relay for arbitrary image/audio processing.
|
||||
*
|
||||
* The upstream SDK demo's routes (apps/web/src/app/api/ai/*) are COMPLETELY
|
||||
* unauthenticated. This module is the fix; every ported route must call it
|
||||
* before doing any work.
|
||||
*
|
||||
* WHO IS TRUSTED
|
||||
* We do not verify a JWT here and we do not hold any signing key. The single
|
||||
* source of truth for "is this caller signed in" is the Shell BFF
|
||||
* (`${BFF_ORIGIN}/api/session/context`), which owns the HttpOnly session cookie.
|
||||
* We forward the caller's raw `cookie` header to it and treat a 200 as proof of
|
||||
* a session. Consequences of that choice, stated explicitly:
|
||||
*
|
||||
* - The BFF is trusted absolutely. If it is compromised or misconfigured to
|
||||
* answer 200 for anonymous callers, these routes are open. BFF_ORIGIN must
|
||||
* therefore only ever point at an origin the operator controls.
|
||||
* - We forward the cookie header verbatim and nothing else. No Authorization
|
||||
* header, no bearer token, and never the RunPod key.
|
||||
* - NOTHING IS CACHED. A cached "yes" would keep a revoked/expired session
|
||||
* alive for the cache lifetime, so every AI request costs one BFF round
|
||||
* trip. That is deliberate: correctness over latency for a spend gate.
|
||||
* - A network failure reaching the BFF returns 503 (fail CLOSED), never 200.
|
||||
* If we cannot prove a session, we do not spend GPU budget.
|
||||
*
|
||||
* DEMO MODE
|
||||
* When the Shell is not configured (`NEXT_PUBLIC_SUPABASE_URL` unset) the CRM
|
||||
* runs on its mock portal and there is no session to check, so we allow the
|
||||
* request and log ONCE at startup-of-first-use. This is the same gate
|
||||
* `isShellConfigured()` uses for mock-vs-real auth elsewhere in the app.
|
||||
* IMPORTANT: never deploy to a public origin with the Shell unconfigured AND a
|
||||
* real RUNPOD_API_KEY present — that combination is an open, billable endpoint.
|
||||
*
|
||||
* WHAT THIS IS NOT
|
||||
* This is authentication only, not authorization. It answers "is there a valid
|
||||
* session", not "may this principal use the gallery". Per-resource policy for
|
||||
* gallery data lives in be-crm behind the `crm.gallery` resource; if these AI
|
||||
* routes ever need the same, check it there rather than re-deriving it here.
|
||||
*/
|
||||
|
||||
export type GallerySession =
|
||||
| { ok: true; principalId?: string }
|
||||
| { ok: false; status: number; error: string };
|
||||
|
||||
/** Mirrors src/lib/appshell.ts — kept local so this stays server-only. */
|
||||
function isShellConfigured(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
}
|
||||
|
||||
let demoModeWarned = false;
|
||||
|
||||
/**
|
||||
* Resolve the caller's session. Returns `{ ok: true }` (optionally with the
|
||||
* principal id, used to key rate limits) or a ready-to-return failure with the
|
||||
* status and message the route should emit.
|
||||
*/
|
||||
export async function requireGallerySession(req: Request): Promise<GallerySession> {
|
||||
if (!isShellConfigured()) {
|
||||
if (!demoModeWarned) {
|
||||
demoModeWarned = true;
|
||||
console.warn(
|
||||
"[gallery-ai] Shell is not configured (NEXT_PUBLIC_SUPABASE_URL unset) — " +
|
||||
"AI routes are UNAUTHENTICATED in demo mode. Do not expose this deployment publicly " +
|
||||
"while RUNPOD_API_KEY is set.",
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const cookie = req.headers.get("cookie");
|
||||
if (!cookie) return { ok: false, status: 401, error: "Not signed in" };
|
||||
|
||||
const origin = (process.env.BFF_ORIGIN ?? "http://localhost:4000").replace(/\/$/, "");
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${origin}/api/session/context`, {
|
||||
headers: { cookie, accept: "application/json" },
|
||||
// Never cache an auth decision — see the trust-model note above.
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Fail closed: we could not prove a session, so we do not spend GPU budget.
|
||||
return { ok: false, status: 503, error: "Session service unavailable" };
|
||||
}
|
||||
|
||||
if (res.status === 401) return { ok: false, status: 401, error: "Not signed in" };
|
||||
if (!res.ok) return { ok: false, status: 503, error: "Session service unavailable" };
|
||||
|
||||
// The principal id is best-effort: it only sharpens the rate-limit key, so a
|
||||
// shape we don't recognize degrades to IP-keyed limiting rather than failing.
|
||||
let principalId: string | undefined;
|
||||
try {
|
||||
const data = (await res.json()) as Record<string, unknown> | null;
|
||||
principalId = pickPrincipalId(data);
|
||||
} catch {
|
||||
/* ignore — see above */
|
||||
}
|
||||
|
||||
return principalId ? { ok: true, principalId } : { ok: true };
|
||||
}
|
||||
|
||||
function pickPrincipalId(data: Record<string, unknown> | null): string | undefined {
|
||||
if (!data) return undefined;
|
||||
const direct = data.userId ?? data.principalId ?? data.sub ?? data.id;
|
||||
if (typeof direct === "string" && direct) return direct;
|
||||
const user = data.user;
|
||||
if (user && typeof user === "object") {
|
||||
const u = user as Record<string, unknown>;
|
||||
const nested = u.id ?? u.userId ?? u.sub;
|
||||
if (typeof nested === "string" && nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate-limit key for a request: the authenticated principal when known,
|
||||
* otherwise the first hop of `x-forwarded-for`.
|
||||
*
|
||||
* NOTE the first hop is client-controlled unless a trusted proxy overwrites the
|
||||
* header. It is good enough to throttle honest clients and casual abuse; it is
|
||||
* NOT a security boundary. The session gate above is the security boundary.
|
||||
*/
|
||||
export function rateLimitKey(req: Request, principalId?: string): string {
|
||||
if (principalId) return `u:${principalId}`;
|
||||
const xff = req.headers.get("x-forwarded-for") ?? "";
|
||||
const first = xff.split(",")[0]?.trim();
|
||||
return `ip:${first || "unknown"}`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
// SMS settings data layer. Serves EITHER the local mock (Shell not configured — the demo keeps
|
||||
// working) OR the live be-crm data door (crm.settings.sms.*), behind one interface.
|
||||
//
|
||||
// Live contract (be-crm → IIOS BYO credential store):
|
||||
// query crm.settings.sms.status {} -> { configured, enabled?, hints? }
|
||||
// cmd crm.settings.sms.configure { accountSid, authToken, fromNumber } -> masked status
|
||||
// The auth token is write-only: it is sealed in IIOS and NEVER returned — status carries only
|
||||
// non-secret hints (from-number + SID last-4).
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
export interface SmsCredentials { accountSid: string; authToken: string; fromNumber: string }
|
||||
|
||||
export interface SmsStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
fromNumber?: string;
|
||||
sidLast4?: string;
|
||||
}
|
||||
|
||||
export interface SmsSettingsData {
|
||||
live: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
status: SmsStatus;
|
||||
configure: (input: SmsCredentials) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { fromNumber?: string; sidLast4?: string } }
|
||||
|
||||
function toStatus(dto?: StatusDTO | null): SmsStatus {
|
||||
return {
|
||||
configured: !!dto?.configured,
|
||||
enabled: dto?.enabled ?? false,
|
||||
fromNumber: dto?.hints?.fromNumber,
|
||||
sidLast4: dto?.hints?.sidLast4,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Mock (demo mode) — stores only the non-secret hints, mirroring the masked live status ---- */
|
||||
function useMockSms(): SmsSettingsData {
|
||||
const [status, setStatus] = useState<SmsStatus>({ configured: false, enabled: false });
|
||||
const configure = useCallback(async ({ accountSid, fromNumber }: SmsCredentials) => {
|
||||
setStatus({ configured: true, enabled: true, fromNumber, sidLast4: accountSid.slice(-4) });
|
||||
}, []);
|
||||
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
|
||||
}
|
||||
|
||||
/* ---- Live (be-crm data door) ---- */
|
||||
function useLiveSms(): SmsSettingsData {
|
||||
const { sdk } = useAppShell();
|
||||
const q = useQuery<StatusDTO>("crm.settings.sms.status", {});
|
||||
const configure = useCallback(async (input: SmsCredentials) => {
|
||||
await sdk.command("crm.settings.sms.configure", { ...input });
|
||||
q.refetch();
|
||||
}, [sdk, q]);
|
||||
return {
|
||||
live: true,
|
||||
loading: q.loading,
|
||||
error: q.error ? String(q.error) : null,
|
||||
status: toStatus(q.data),
|
||||
configure,
|
||||
refetch: q.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function useSmsSettings(): SmsSettingsData {
|
||||
// SHELL is constant for the bundle's life (NEXT_PUBLIC_* is build-time), so the same hook path
|
||||
// runs every render — Rules-of-Hooks safe.
|
||||
return SHELL ? useLiveSms() : useMockSms();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
// SMTP settings data layer — a tenant's own outbound email server (BYO). Serves the local mock
|
||||
// (Shell not configured) or the live be-crm data door (crm.settings.smtp.*).
|
||||
//
|
||||
// Live contract (be-crm → IIOS BYO credential store):
|
||||
// query crm.settings.smtp.status {} -> { configured, enabled?, hints? }
|
||||
// cmd crm.settings.smtp.configure { host, port, secure, user, pass, fromEmail, fromName? } -> masked status
|
||||
// The password is write-only: sealed in IIOS, never returned — status carries only non-secret hints.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
export interface SmtpCredentials {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
pass: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
export interface SmtpStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
export interface SmtpSettingsData {
|
||||
live: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
status: SmtpStatus;
|
||||
configure: (input: SmtpCredentials) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { host?: string; port?: number; user?: string; fromEmail?: string; fromName?: string } }
|
||||
|
||||
function toStatus(dto?: StatusDTO | null): SmtpStatus {
|
||||
return {
|
||||
configured: !!dto?.configured,
|
||||
enabled: dto?.enabled ?? false,
|
||||
host: dto?.hints?.host,
|
||||
port: dto?.hints?.port,
|
||||
user: dto?.hints?.user,
|
||||
fromEmail: dto?.hints?.fromEmail,
|
||||
fromName: dto?.hints?.fromName,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Mock (demo mode) — stores only the non-secret hints ---- */
|
||||
function useMockSmtp(): SmtpSettingsData {
|
||||
const [status, setStatus] = useState<SmtpStatus>({ configured: false, enabled: false });
|
||||
const configure = useCallback(async ({ host, port, user, fromEmail, fromName }: SmtpCredentials) => {
|
||||
setStatus({ configured: true, enabled: true, host, port, user, fromEmail, ...(fromName ? { fromName } : {}) });
|
||||
}, []);
|
||||
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
|
||||
}
|
||||
|
||||
/* ---- Live (be-crm data door) ---- */
|
||||
function useLiveSmtp(): SmtpSettingsData {
|
||||
const { sdk } = useAppShell();
|
||||
const q = useQuery<StatusDTO>("crm.settings.smtp.status", {});
|
||||
const configure = useCallback(async (input: SmtpCredentials) => {
|
||||
await sdk.command("crm.settings.smtp.configure", { ...input });
|
||||
q.refetch();
|
||||
}, [sdk, q]);
|
||||
return {
|
||||
live: true,
|
||||
loading: q.loading,
|
||||
error: q.error ? String(q.error) : null,
|
||||
status: toStatus(q.data),
|
||||
configure,
|
||||
refetch: q.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function useSmtpSettings(): SmtpSettingsData {
|
||||
return SHELL ? useLiveSmtp() : useMockSmtp();
|
||||
}
|
||||
Reference in New Issue
Block a user