// screens-cameras-avatars.jsx — the Avatars tab of the Cameras page. // // Mirrors ui/screens-cameras.jsx for structure: plain window.* component // library, window.api.get/post, useLive polling, no build step // (Babel-in-browser). Exposes window.AvatarsTab, rendered by // screens-cameras.jsx's tab switcher with { site, dvrs, cameras, notify, // onChanged } (only { site } is guaranteed — everything else is re-fetched // here when absent). // // Sections (design-avatars §7): avatar registry cards + create/edit modal, // reference gallery (pinned operator-approved images + photo upload), // enrollment scan launcher + scans list with progress, and the pending // candidate review grid (approve / reject / assign-to-group). // // Data lives behind /ui/api/cameras/avatars[…] (camera_avatars.go + // camera_avatar_scan.go) — admin-gated like the rest of the panel; the // session cookie covers requests to /camera/media/. (() => { const { useState, useEffect, useMemo, useCallback, useRef } = React; const { Icon, api, useLive, timeAgo, Button, IconButton, Card, Field, Input, Textarea, Select, Switch, Checkbox, Badge, Modal, useToast, EmptyState, Spinner, } = window; /* ───────────────────────────────────────────────────────────── Helpers ───────────────────────────────────────────────────────────── */ function mediaURL(token) { return token ? "/camera/media/" + token : ""; } function ago(ts) { if (!ts) return "—"; const t = Date.parse(ts); return isNaN(t) ? ts : timeAgo(t); } // localTime renders an RFC3339 UTC instant in the browser's local time — // frame_ts is stored UTC; operators think in wall-clock time. function localTime(ts) { if (!ts) return "—"; const t = Date.parse(ts); return isNaN(t) ? ts : new Date(t).toLocaleString(); } // parseDvrIds tolerates the two shapes avatar.dvr_ids arrives in — a JSON // array (typical) or the raw TEXT column value ("" / "[…]"). function parseDvrIds(raw) { if (Array.isArray(raw)) return raw; if (typeof raw === "string" && raw.trim()) { try { const v = JSON.parse(raw); return Array.isArray(v) ? v : []; } catch (e) { return []; } } return []; } // parseProgress tolerates scan.progress as a JSON string or an object. function parseProgress(raw) { if (!raw) return null; if (typeof raw === "object") return raw; try { return JSON.parse(raw); } catch (e) { return null; } } function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = () => reject(fr.error || new Error("could not read the file")); fr.readAsDataURL(file); }); } // defaultScanWindow: yesterday 06:00 → 20:00 local, as datetime-local values. function defaultScanWindow() { const d = new Date(); d.setDate(d.getDate() - 1); const pad = (n) => String(n).padStart(2, "0"); const ymd = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()); return { from: ymd + "T06:00", to: ymd + "T20:00" }; } const AVATAR_TYPE_OPTIONS = [ { value: "human", label: "Human" }, { value: "vehicle", label: "Vehicle" }, { value: "pet", label: "Pet" }, ]; const TYPE_TONE = { human: "accent", vehicle: "info", pet: "warning" }; const SCAN_STATUS_TONE = { queued: "info", running: "info", done: "success", error: "danger", cancelled: "neutral" }; /* ───────────────────────────────────────────────────────────── Avatar registry — list cards + create/edit modal ───────────────────────────────────────────────────────────── */ function AvatarListCard({ avatar, active, onSelect, onEdit, onDelete, onScan, onToggle }) { const refs = avatar.ref_count || 0; return (
{avatar.name} { e.stopPropagation(); onEdit(); }} /> { e.stopPropagation(); onDelete(); }} />
{avatar.type || "human"} {avatar.is_group ? group : null} {refs} ref{refs === 1 ? "" : "s"}
{avatar.description &&

{avatar.description}

}
); } function AvatarFormModal({ open, avatar, siteId, dvrs, notify, onClose, onSaved }) { const blank = { name: "", type: "human", is_group: false, external_ref: "", description: "", dvr_ids: [] }; const [form, setForm] = useState(blank); const [photo, setPhoto] = useState(null); const [saving, setSaving] = useState(false); const fileRef = useRef(null); useEffect(() => { if (!open) return; setPhoto(null); if (fileRef.current) fileRef.current.value = ""; setForm(avatar ? { name: avatar.name || "", type: avatar.type || "human", is_group: !!avatar.is_group, external_ref: avatar.external_ref || "", description: avatar.description || "", dvr_ids: parseDvrIds(avatar.dvr_ids), } : blank); }, [open, avatar]); const toggleDvr = (id) => { setForm((f) => ({ ...f, dvr_ids: f.dvr_ids.includes(id) ? f.dvr_ids.filter((x) => x !== id) : [...f.dvr_ids, id], })); }; const save = useCallback(async () => { const name = form.name.trim(); if (!name) { notify("Name is required", "error"); return; } setSaving(true); try { const fields = { name, type: form.type, is_group: form.is_group, external_ref: form.external_ref, description: form.description, dvr_ids: form.dvr_ids, }; if (avatar) { const res = await api.post("/ui/api/cameras/avatars/update", { id: avatar.id, ...fields, enabled: avatar.enabled !== false }); if (!res || res.ok === false) { notify((res && res.error) || "Save failed", "error"); return; } notify("Avatar updated", "success"); onSaved(avatar.id); } else { const res = await api.post("/ui/api/cameras/avatars", { site_id: siteId, ...fields }); if (!res || res.ok === false) { notify((res && res.error) || "Save failed", "error"); return; } const newId = res.id || ""; if (photo && newId) { try { const dataURL = await readFileAsDataURL(photo); const up = await api.post("/ui/api/cameras/avatars/media/upload", { avatar_id: newId, image_b64: dataURL, note: "" }, { timeoutMs: 60000 }); if (!up || up.ok === false) notify("Avatar created, but the photo upload failed: " + ((up && up.error) || "unknown error"), "error"); } catch (e) { notify("Avatar created, but the photo upload failed: " + ((e && e.message) || e), "error"); } } notify("Avatar created", "success"); onSaved(newId); } } catch (e) { notify("Save failed: " + ((e && e.message) || e), "error"); } finally { setSaving(false); } }, [form, photo, avatar, siteId, notify, onSaved]); return ( } > setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Dr. Ahmad" disabled={saving} />
setForm((f) => ({ ...f, external_ref: e.target.value }))} disabled={saving} />
setForm((f) => ({ ...f, is_group: v }))} label="Group — a class of people (e.g. patients, customers), not one individual" disabled={saving} />