/** * Admin page templates: overview, cameras, kiosks, account, etc. */ import { js } from "jsx-htmx"; import { Layout } from "./layout.js"; import type { AuditEntry, Camera, CameraEventSubscription, Display, Entity, FirmwareRelease, FirmwareRollout, Kiosk, KioskGpioBinding, KioskLog, Label, Layout as LayoutType, LayoutCell, OsUpdateRelease, OsUpdateRollout, PairingCode, EventLog, } from "../shared/types.js"; // ---- Overview --------------------------------------------------------------- interface OverviewProps { user: string; cameraCount: number; kioskCount: number; onlineKioskCount: number; layoutCount: number; events: EventLog[]; } export function OverviewPage(props: OverviewProps) { return (
Cameras
{String(props.cameraCount)}
Kiosks
{String(props.kioskCount)}
Kiosks Online
{String(props.onlineKioskCount)}
Displays
{String(props.layoutCount)}

Quick Links

Add Camera
RTSP or ONVIF
Pair Kiosk
Enter pairing code
Rule Engine
Node-RED dashboard

Recent Events

{props.events.length === 0 ? ( ) : ( props.events.map((ev) => ( )) )}
Time Topic Source Payload
No events yet
{formatTime(ev.received_at)} {ev.topic} {ev.source_type} {JSON.stringify(ev.payload)}
); } // ---- Cameras ---------------------------------------------------------------- interface CamerasProps { user: string; cameras: Camera[]; streamCounts: Map; activeKiosks: Map; } export function CamerasPage(props: CamerasProps) { return (

All Cameras

Add Camera
{props.cameras.length === 0 ? ( ) : ( props.cameras.map((cam) => { const streams = props.streamCounts.get(cam.id) ?? 0; const active = props.activeKiosks.get(cam.id) ?? 0; const health = !cam.enabled ? "gray" : streams === 0 ? "red" : active > 0 ? "green" : "yellow"; const healthLabel = !cam.enabled ? "Disabled" : streams === 0 ? "No streams" : active > 0 ? `Live (${active} kiosk${active > 1 ? "s" : ""})` : "Idle"; return ( ); }) )}
Name Type Streams Status
No cameras configured
{cam.name} {cam.type.toUpperCase()} {String(streams)} {healthLabel}
); } // ---- Camera New ------------------------------------------------------------- interface CameraNewProps { user: string; error?: string; values?: Record; } export function CameraNewPage(props: CameraNewProps) { const v = props.values ?? {}; return (
Cancel
); } // ---- Camera ONVIF Discovery ------------------------------------------------ interface CameraDiscoverProps { user: string; kiosks: Kiosk[]; error?: string; values?: Record; } export function CameraDiscoverPage(props: CameraDiscoverProps) { const v = props.values ?? {}; return (

Connect to an ONVIF camera or NVR by host and credentials. Profiles from the same video source are imported as streams on one camera.

Cancel
); } interface DiscoveredProfileRow { profile_name: string; profile_token: string; source_token: string | null; encoding: string | null; width: number | null; height: number | null; framerate: number | null; stream_uri: string; snapshot_uri: string | null; role: "main" | "sub" | "other"; } interface DiscoveredCameraRow { name: string; source_token: string | null; profiles: DiscoveredProfileRow[]; } interface CameraDiscoverResultsProps { user: string; host: string; port?: number; username: string; password: string; cameras: DiscoveredCameraRow[]; error?: string; success?: string; } function discoverResultsScript(rootId: string): string { return ( `(function(){` + `var root=document.getElementById('${rootId}');if(!root)return;` + `var checks=function(){return Array.prototype.slice.call(root.querySelectorAll('input[name="selected"]'));};` + `root.querySelector('[data-action="check-all"]')?.addEventListener('click',function(){checks().forEach(function(c){c.checked=true;});});` + `root.querySelector('[data-action="uncheck-all"]')?.addEventListener('click',function(){checks().forEach(function(c){c.checked=false;});});` + `root.querySelector('[data-view="list"]')?.addEventListener('click',function(){root.dataset.view='list';});` + `root.querySelector('[data-view="cards"]')?.addEventListener('click',function(){root.dataset.view='cards';});` + `})()` ); } const DISCOVER_RESULTS_CSS = ` #discover-results-root[data-view="cards"] .discover-results { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; } #discover-results-root[data-view="list"] .discover-camera-card { margin-bottom: 1rem; } #discover-results-root[data-view="list"] .discover-snaps { display: none; } .discover-camera-card { margin-bottom: 1rem; } .discover-snaps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; } .discover-snap { position: relative; min-height: 150px; background: #111827; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; } .discover-snap img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; } .discover-snap-label { position: absolute; left: 6px; top: 6px; background: rgba(17, 24, 39, 0.8); color: #fff; border-radius: 3px; padding: 2px 5px; font-size: 0.7rem; text-transform: uppercase; z-index: 1; } .discover-snap-empty { color: #d1d5db; font-size: 0.8rem; } `; function CameraDiscoverResultsPageLegacy(props: { user: string; host: string; profiles: DiscoveredProfileRow[]; error?: string; success?: string; }) { return (

Video sources reported by {props.host}. Each source imports as one camera with its profiles saved as streams.

{props.profiles.length === 0 ? ( ) : ( props.profiles.map((p) => ( )) )}
Profile Encoding Resolution FPS Stream URI
No profiles returned
{p.profile_name} {p.encoding ? {p.encoding} : "—"} {p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "—"} {p.framerate != null ? String(p.framerate) : "—"} {p.stream_uri}
); } export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { return (

Video sources reported by {props.host}. Each source imports as one camera with its profiles saved as streams.

{props.cameras.length === 0 ? (
No profiles returned
) : (
{props.cameras.map((cam, idx) => { const main = cam.profiles.find((p) => p.role === "main") ?? cam.profiles[0] ?? null; const sub = cam.profiles.find((p) => p.role === "sub") ?? null; return (
{[main, sub].filter(Boolean).map((p) => (
{p!.role}
{p!.snapshot_uri ? :
No snapshot
}
))}
{cam.profiles.map((p) => ( ))}
Role Profile Encoding Resolution FPS Stream URI
{p.role} {p.profile_name} {p.encoding ? {p.encoding} : "-"} {p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"} {p.framerate != null ? String(p.framerate) : "-"} {p.stream_uri}
); }).join("")}
)}
); } // ---- Entities --------------------------------------------------------------- interface EntitiesPageProps { user: string; entities: Entity[]; } function entityBadge(type: string) { const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : type === "dashboard" ? "badge-blue" : "badge-gray"; return {type}; } function entityDetail(e: Entity): string { if (e.type === "camera") return e.camera_id ? `cam #${String(e.camera_id)}` : "—"; if (e.type === "web") return e.web_url ?? "—"; if (e.type === "html") return e.html_content ? `${e.html_content.slice(0, 80)}…` : "—"; if (e.type === "dashboard") return e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"; return "—"; } export function EntitiesPage(props: EntitiesPageProps) { const dashboards = props.entities.filter((e) => e.type === "dashboard"); const others = props.entities.filter((e) => e.type !== "dashboard"); return (

All Entities

New Entity

Entities are reusable content blocks (a camera reference, an HTML snippet, a web page, or a Node-RED dashboard tab). Bind one entity to any number of layout cells — edit the entity once and every cell updates.

{others.length === 0 ? ( ) : ( others.map((e) => ( )) )}
Name Type Detail
No entities yet
{e.name} {entityBadge(e.type)} {entityDetail(e)}

Dashboards (Node-RED)

Auto-synced from Node-RED. Press Sync Dashboards after adding or renaming tabs in Node-RED. Editing a dashboard happens in the Node-RED editor.

{dashboards.length === 0 ? ( ) : ( dashboards.map((e) => ( )) )}
Name Tab ID URL
No dashboards synced yet — press Sync.
{e.name} {e.dashboard_id ?? "—"} {e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"}
); } interface EntityNewPageProps { user: string; cameras: Camera[]; error?: string; values?: Record; } function entityFormScript(rootId: string): string { // Show/hide type-specific fieldsets when the type select changes. return ( `(function(){` + `var root=document.getElementById('${rootId}');` + `if(!root)return;` + `var sel=root.querySelector('select[name="type"]');` + `var cf=root.querySelector('.ent-fields-camera');` + `var wf=root.querySelector('.ent-fields-web');` + `var hf=root.querySelector('.ent-fields-html');` + `function t(){var v=sel?sel.value:"html";` + `if(cf)cf.style.display=v==='camera'?'block':'none';` + `if(wf)wf.style.display=v==='web'?'block':'none';` + `if(hf)hf.style.display=v==='html'?'block':'none';}` + `if(sel)sel.addEventListener('change',t);t();})()` ); } export function EntityNewPage(props: EntityNewPageProps) { const v = props.values ?? {}; const selType = v["type"] ?? "html"; return (
Cancel
); } interface EntityEditPageProps { user: string; entity: Entity; cameras: Camera[]; error?: string; success?: string; } export function EntityEditPage(props: EntityEditPageProps) { const e = props.entity; return (
Type: {entityBadge(e.type)}
{/* Type is fixed after creation — switching type would break attached cells. */}
{e.type === "camera" && (
)} {e.type === "camera" && e.camera_id != null && (
Camera snapshot Snapshot failed — camera unreachable or RTSP not configured
Pulls one frame via ffmpeg/gst (up to ~8s).
)} {e.type === "web" && (
)} {e.type === "html" && (
)} {e.type === "dashboard" && (
{e.dashboard_id ?? "—"}
Synced from Node-RED. Resolved as /dash/{e.dashboard_id ?? "?"} in kiosk bundles. Edit the dashboard contents in the Node-RED editor.
)} Back {e.type === "dashboard" && ( Open in Node-RED )}
); } // ---- Kiosks ----------------------------------------------------------------- interface KiosksProps { user: string; kiosks: Kiosk[]; pendingCodes: PairingCode[]; error?: string; } export function KiosksPage(props: KiosksProps) { return (

Paired Kiosks

{props.kiosks.length === 0 ? ( ) : ( props.kiosks.map((k) => ( )) )}
Name Hardware Last Seen Status
No kiosks paired
{k.name} {k.hardware_model ?? "—"} {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"} {k.enabled ? Active : Disabled }

Pair New Kiosk

8-character code shown on kiosk screen.
Pick the kiosk this device replaces. Display, layouts, labels, and GPIO bindings stay; only the device credentials roll. Old kiosk's key is revoked.
Comma-separated label names.
{props.pendingCodes.length > 0 && (
Pending Codes
{props.pendingCodes.map((pc) => { const managed = pc.extras?.["managed_image"] === true; return (
{pc.code} expires {formatTime(pc.expires_at)}
{pc.kiosk_proposed_name ? <>name: {pc.kiosk_proposed_name} : "(no name)"} {pc.kiosk_hardware_model ? <> · hw: {pc.kiosk_hardware_model} : null} {managed ? <> · managed image : null}
{pc.kiosk_capabilities?.length > 0 ? (
caps: {pc.kiosk_capabilities.join(", ")}
) : null}
); })}
)}
); } // ---- Account ---------------------------------------------------------------- interface AccountProps { user: string; totpEnabled: boolean; error?: string; success?: string; } export function AccountPage(props: AccountProps) { return (

Change Password

At least 12 characters.

Two-Factor Authentication

{props.totpEnabled ? (

Enabled {" "}TOTP is active on this account.

) : (

Protect your account with a TOTP authenticator app.

)}
); } // ---- TOTP Enrollment -------------------------------------------------------- interface TotpEnrollProps { user: string; secret: string; provisioningUri: string; recoveryCodes: string[]; } export function TotpEnrollPage(props: TotpEnrollProps) { return (

Step 1: Scan QR Code

Scan this with your authenticator app (Google Authenticator, Authy, etc.).

Can't scan? Enter manually {props.secret}

Step 2: Save Recovery Codes

Save these codes somewhere safe. They will not be shown again.

{props.recoveryCodes.map((code) => (
{code}
))}

Step 3: Verify

Cancel
); } // ---- Simple list page ------------------------------------------------------- interface SimpleListProps { user: string; pageTitle: string; description: string; activeNav: string; items: Array<{ name: string; detail?: string; badge?: string }>; } export function SimpleListPage(props: SimpleListProps) { return (

{props.description}

{props.items.length === 0 ? ( ) : ( props.items.map((item) => ( )) )}
Name Details
None configured yet
{item.name} {item.badge && ( {item.badge} )} {item.detail ?? ""}
); } // ---- Camera Edit ------------------------------------------------------------ interface CameraSubscription { kiosk: Kiosk; layouts: string[]; // layout names that reference this camera active: boolean; // true if camera is in the kiosk's active layout right now } interface CameraEditProps { user: string; camera: Camera; labels: Array<{ label_id: number; name: string }>; allLabels: Label[]; streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>; subscriptions: CameraSubscription[]; eventSubscriptions?: CameraEventSubscription[]; error?: string; success?: string; } /** * Render the camera labels region (chips + add forms). Returned standalone so * htmx label add/remove can swap just this fragment via * hx-target="#camera-labels-" hx-swap="innerHTML". */ export function renderCameraLabels( cameraId: number, labels: Array<{ label_id: number; name: string }>, allLabels: Label[], ): string { const labelsTargetSelector = `#camera-labels-${String(cameraId)}`; return (
{labels.length > 0 ? (
{labels.map((l) => ( ))}
) : (

No labels attached

)}
); } export function CameraEditPage(props: CameraEditProps) { const cam = props.camera; return (
{cam.type === "cloud" && (

Cloud {cam.name}

This camera is managed by a cloud account sync. It cannot be edited manually. Changes are applied automatically when the cloud account is synced.

Type: Cloud ({cam.cloud_stream_type ?? "unknown stream"})
Vendor Camera ID: {cam.cloud_vendor_camera_id ?? "—"}
{cam.cloud_stream_url && (
Stream URL: {cam.cloud_stream_url}
)}
Status: {cam.enabled ? Enabled : Disabled}
Last seen: {cam.last_seen_at ? formatTime(cam.last_seen_at) : "Never"}
Back
)} {cam.type !== "cloud" && (<>

Edit Camera

{cam.type === "rtsp" && (() => { const parts = parseRtspUrl(cam.rtsp_url ?? ""); return (
); })()} {cam.type === "onvif" && (
)}
{cam.type === "onvif" && (

Event Routing

)} Back
{cam.type === "onvif" && (() => { const subs = props.eventSubscriptions ?? []; const subMap = new Map(subs.map((s) => [s.topic, s])); // Merge: show all topics from subscriptions table + any in supported_event_topics not yet in DB const allTopics = new Set([...subs.map((s) => s.topic), ...cam.supported_event_topics]); const sortedTopics = [...allTopics].sort(); const hasInactive = subs.some((s) => s.status === "inactive"); return (

Event Topics & Subscriptions

Topics this camera advertises via GetEventProperties. Click refresh to re-query the camera (via the designated event source). New topics are merged into the list and never removed.

{hasInactive && (
)}
{sortedTopics.length > 0 ? (
{[...sortedTopics].map((t) => { const sub = subMap.get(t); const status = sub?.status ?? "inactive"; const dotColor = status === "active" ? "#22c55e" : status === "pending" ? "#f59e0b" : status === "failed" ? "#ef4444" : "#9ca3af"; const dotTitle = status === "active" ? "Active" : status === "pending" ? "Pending" : status === "failed" ? "Failed" : "Inactive"; return ( ); })}
StatusTopicSourceSinkLast EventError
{t} {sub?.event_source ?? "—"} {sub?.event_sink ?? "—"} {sub?.last_event_at ? formatTime(sub.last_event_at) : "—"} {sub?.error_message ?? ""}
) : (

No topics discovered yet. Click refresh above.

)}
); })()}

Labels

{renderCameraLabels(cam.id, props.labels, props.allLabels)}

Streams

{props.streams.length > 0 ? (
{props.streams.map((s) => ( ))}
RoleNameURI
{s.role} {s.name} {maskRtspPassword(s.rtsp_uri)}
) : (

No streams configured

)}
{cam.type === "onvif" && (

Live Events

ONVIF events from kiosks subscribed to this camera. Auto-refreshes every 5s. All topics shown — motion, ANPR, line crossing, I/O, analytics, unknown.

Loading...
)}

Kiosk Subscriptions

Kiosks whose layouts reference this camera. Snapshots are pulled from a subscribed kiosk (same LAN as camera) when available.

{props.subscriptions.length > 0 ? (
{props.subscriptions.map((sub) => ( ))}
KioskLayoutsStatus
{sub.kiosk.name} {sub.layouts.join(", ") || "—"} {sub.active ? active : bundled}
) : (

No kiosk has this camera in any layout.

)}
)}
); } // ---- Kiosk Edit ------------------------------------------------------------- interface KioskEditProps { user: string; kiosk: Kiosk; labels: Array<{ label_id: number; name: string; role: string }>; allLabels: Label[]; displays?: Display[]; displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>; gpioBindings?: KioskGpioBinding[]; firmwareReleases?: FirmwareRelease[]; osReleases?: OsUpdateRelease[]; kioskLogs?: KioskLog[]; kioskLogTotal?: number; error?: string; success?: string; } /** * Render the kiosk labels region (chips + add forms). Returned standalone so * htmx label add/remove can swap just this fragment via * hx-target="#kiosk-labels-" hx-swap="innerHTML". */ export function renderKioskLabels( kioskId: number, labels: Array<{ label_id: number; name: string; role: string }>, allLabels: Label[], ): string { const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`; return (
{labels.length > 0 ? (
{labels.map((l) => ( ))}
) : (

No labels attached

)}
); } /** * Managed-image device config editor. Only rendered when the kiosk reported * managed_image=true at pairing. Server pushes the resulting JSON on the * next heartbeat; kiosk applies it and echoes the version back, so we show * "version N applied at …" plus the last error (if any) so the operator can * see whether their change actually landed. */ function ManagedConfigCard(props: { kiosk: Kiosk }) { const k = props.kiosk; let cfg: { hostname?: string; timezone?: string; network?: { mode?: string; interface?: string; ip_cidr?: string; gateway?: string; dns?: string[]; vlan_id?: number; }; wifi?: { ssid?: string }; } = {}; if (k.managed_config_json) { try { cfg = JSON.parse(k.managed_config_json); } catch { /* ignore */ } } const net = cfg.network ?? {}; const wifi = cfg.wifi ?? {}; const pending = k.managed_config_version > k.managed_config_applied_version; return (

Managed Config (Pi image)

Version: {String(k.managed_config_version)} {" · Applied: "}{String(k.managed_config_applied_version)} {k.managed_config_applied_at ? <> ({formatTime(k.managed_config_applied_at)}) : null} {pending ? pending push… : null}
{k.managed_config_error ?
Last error: {k.managed_config_error}
: null}

Network

Wi-Fi (optional)

Encrypted with cluster key before storage. Leave blank to keep existing PSK.
); } export function KioskEditPage(props: KioskEditProps) { const k = props.kiosk; return (

Edit Kiosk

Back
Hardware: {k.hardware_model ?? "—"}
Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}
Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}
Display Power
{props.displayLayouts && props.displayLayouts.length > 0 ? (
Switch Layout By Display
{props.displayLayouts.map(({ display, layouts }) => (
{display.name}
{String(display.width_px)}x{String(display.height_px)}
{layouts.length > 0 ? ( ) : ( No attached layouts )}
)).join("")}
) : null}
Hardware
CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}
Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}
CPU Load: {percentText(k.cpu_load_percent)}
RAM: {mbPair(k.memory_used_mb, k.memory_total_mb)}
Disk: {k.disk_free_mb != null && k.disk_total_mb != null ? `${String(k.disk_free_mb)} MB free / ${String(k.disk_total_mb)} MB` : "—"} {k.disk_used_percent != null ? `(${k.disk_used_percent.toFixed(1)}%)` : ""}
PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}
{/* Associated displays */}

Displays

{props.displays && props.displays.length > 0 ? (
{props.displays.map((d) => ( ))}
NameResolutionIndexPower
{d.name} {String(d.width_px)}x{String(d.height_px)} {String(d.index)} {powerBadge(d.actual_power_state)}
) : (

No displays associated with this kiosk

)}
{props.firmwareReleases && ( KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases }) )} {props.osReleases && ( KioskOsUpdatePanel({ kiosk: props.kiosk, releases: props.osReleases }) )} {(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })} {/* GPIO bindings */}

GPIO Bindings

Each input binding fires an event with the configured topic when the pin's edge triggers. Pi 5's main GPIO chip is gpiochip4; older Pis use gpiochip0.

{props.gpioBindings && props.gpioBindings.length > 0 ? (
{props.gpioBindings.map((g) => ( ))}
Chip Pin Dir Pull Edge Topic
{g.chip} {String(g.pin)} {g.direction} {g.pull ?? "—"} {g.edge ?? "—"} {g.topic}
) : (

No GPIO bindings configured

)}

Labels

{renderKioskLabels(k.id, props.labels, props.allLabels)}
{k.managed_image ? : null} {/* Kiosk application logs */}

Logs {props.kioskLogTotal ? ({String(props.kioskLogTotal)}) : null}

{props.kioskLogs && props.kioskLogs.length > 0 ? (
{props.kioskLogs.map((log) => { const levelBadge = log.level === "error" ? "badge-red" : log.level === "warn" ? "badge-yellow" : log.level === "info" ? "badge-blue" : "badge-gray"; const ctx = Object.keys(log.context).length > 0 ? JSON.stringify(log.context) : ""; return ( ); })}
Time Level Message
{log.received_at.replace("T", " ").replace(/\.\d+Z$/, "Z")} {log.level} {log.message} {ctx &&
{ctx}
}
) : (

No logs received from this kiosk

)}
); } // ---- Labels Management ------------------------------------------------------ interface LabelsPageProps { user: string; labels: Label[]; error?: string; } export function LabelsPage(props: LabelsPageProps) { return (

All Labels

{props.labels.length === 0 ? ( ) : ( props.labels.map((l) => ( )) )}
NameColorActions
No labels
{l.name} {l.color ? {l.color} : "—"}
); } // ---- Layouts ---------------------------------------------------------------- interface LayoutsPageProps { user: string; layouts: LayoutType[]; /** layout_id → number of displays the layout is attached to */ displayCounts: Map; } export function LayoutsPage(props: LayoutsPageProps) { return (

All Layouts

New Layout

Layouts are standalone — they define a grid of regions and bind cameras or other content into them. Attach a layout to one or more displays from the display's edit page.

{props.layouts.length === 0 ? ( ) : ( props.layouts.map((l) => { const count = props.displayCounts.get(l.id) ?? 0; return ( ); }) )}
Name Displays Priority
No layouts created yet
{l.name} {count === 0 ? unattached : {String(count)} display{count !== 1 ? "s" : ""}} {l.priority === "hot" ? hot : l.priority === "cold" ? cold : normal }
); } // ---- Layout New ------------------------------------------------------------- interface LayoutNewPageProps { user: string; error?: string; values?: Record; } export function LayoutNewPage(props: LayoutNewPageProps) { const v = props.values ?? {}; return (

Create an empty layout. You'll add cells visually on the next page, then attach the layout to one or more displays.

Cancel
); } // ---- Layout Edit ------------------------------------------------------------ interface LayoutEditPageProps { user: string; layout: LayoutType; /** Displays this layout is attached to (informational, read-only). */ displays: Display[]; cells: LayoutCell[]; cameras: Camera[]; entities: Entity[]; /** If set, render the content-assignment form for this cell beneath the grid. */ selectedCellId?: number | null; error?: string; success?: string; } export const LAYOUT_BUILDER_CSS = ` .layout-builder { display: grid; gap: 4px; aspect-ratio: 16/9; max-width: 100%; background: #ddd; padding: 4px; border-radius: 4px; } .layout-cell { background: #fff; border: 2px solid #2563eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; min-height: 60px; padding: 4px; text-align: center; font-size: 0.85rem; font-weight: 600; color: #1e40af; overflow: visible; } .layout-cell:hover { background: #f0f7ff; } .layout-cell.editing { background: #f9fafb; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; cursor: default; align-items: stretch; justify-content: stretch; text-align: left; font-weight: 400; color: inherit; padding: 8px; overflow: auto; } .layout-cell-empty-text { color: #aaa; font-size: 0.75rem; font-weight: 400; } .layout-cell:has(.layout-cell-empty-text) { border-style: dashed; border-color: #ccc; background: #fafafa; } .layout-cell-side { position: absolute; opacity: 0; transition: opacity 0.2s; z-index: 3; } .layout-cell:hover .layout-cell-side, .layout-cell-side:hover { opacity: 1; } .layout-cell-side-top { top: -12px; left: 50%; transform: translateX(-50%); } .layout-cell-side-right { right: -12px; top: 50%; transform: translateY(-50%); } .layout-cell-side-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); } .layout-cell-side-left { left: -12px; top: 50%; transform: translateY(-50%); } .layout-cell-side-trigger { background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; } .layout-cell-side-trigger:hover { background: #1e40af; } .layout-cell-side-menu { display: none; position: absolute; gap: 4px; background: #111827; border-radius: 4px; padding: 4px; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.25); left: 50%; top: 50%; transform: translate(-50%, -50%); } .layout-cell-side:hover .layout-cell-side-menu { display: flex; } .layout-cell-side-menu button { background: #fff; color: #111827; border: none; border-radius: 3px; cursor: pointer; font-size: 0.7rem; line-height: 1; padding: 5px 7px; white-space: nowrap; } .layout-cell-side-menu button:hover { background: #dbeafe; color: #1e40af; } .layout-cell-delete { position: absolute; top: 4px; right: 4px; background: rgba(220, 38, 38, 0.9); color: #fff; border: none; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; font-size: 12px; line-height: 1; padding: 0; opacity: 0; transition: opacity 0.2s; z-index: 2; } .layout-cell:hover .layout-cell-delete { opacity: 1; } .layout-cell-edit-form { width: 100%; } .layout-cell-edit-form .form-group { margin-bottom: 0.5rem; } .layout-cell-edit-form label { font-size: 0.75rem; font-weight: 600; display: block; margin-bottom: 0.15rem; } .layout-cell-edit-form .form-input, .layout-cell-edit-form input, .layout-cell-edit-form select, .layout-cell-edit-form textarea { font-size: 0.8rem; padding: 0.25rem 0.4rem; width: 100%; box-sizing: border-box; } .layout-cell-edit-form .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; } .layout-cell-edit-form-actions { display: flex; gap: 0.35rem; margin-top: 0.5rem; flex-wrap: wrap; } .layout-cell-edit-form .span-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; } .layout-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/9; background: #f3f4f6; border-radius: 4px; } .layout-empty-add { background: #2563eb; color: #fff; border: none; width: 80px; height: 80px; border-radius: 50%; cursor: pointer; font-size: 36px; line-height: 1; padding: 0; } .layout-empty-add:hover { background: #1e40af; } `; function cellLabel( c: LayoutCell, entityById: Map, cameraById: Map, ): string { if (c.entity_id != null) { const ent = entityById.get(c.entity_id); if (ent) return ent.name; } if (c.content_type === "camera" && c.camera_id) { return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`; } if (c.content_type === "none") return "None"; if (c.content_type === "web") return c.web_url ? `Web: ${c.web_url}` : "Web"; if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)"; return "Empty"; } function cellGridStyle(c: LayoutCell): string { return `grid-column:${String(c.col + 1)} / span ${String(c.col_span)}; grid-row:${String(c.row + 1)} / span ${String(c.row_span)};`; } /** * Render a single cell, either in read-only display mode or edit mode (form * inline inside the cell). Returns a `
` element * suitable for hx-swap="outerHTML" against itself. */ export function renderCell( layoutId: number, c: LayoutCell, entities: Entity[], cameras: Camera[], mode: "read" | "edit", ): string { const cameraById = new Map(); for (const cam of cameras) cameraById.set(cam.id, cam); const entityById = new Map(); for (const e of entities) entityById.set(e.id, e); const style = cellGridStyle(c); const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`; const cellEditUrl = `${cellGetUrl}/edit`; const addUrl = `/admin/layouts/${String(layoutId)}/cells`; const deleteUrl = `${cellGetUrl}/delete`; const resizeUrl = `${cellGetUrl}/resize`; if (mode === "edit") { return (
{/* Smart URL step builder — shown for web/dashboard cells */}
Auto-login / navigate sequences. Leave empty for direct URL load.
{((c.options?.["smart_url"] as any)?.steps ?? []).map((step: any, idx: number) => (
))}
';document.getElementById('steps-${String(c.id)}').appendChild(d)`} >+ Add Step
); } // Read mode. Empty when no entity is bound. const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null; const isEmpty = !ent && ( c.content_type === "none" || (c.content_type === "html" && !c.html_content) || (c.content_type === "camera" && !c.camera_id) || (c.content_type === "web" && !c.web_url) ); const label = cellLabel(c, entityById, cameraById); return (
{isEmpty ? + empty : {label} } {/* + buttons (4 sides) — htmx posts to add endpoint, swaps grid */} {(["top", "right", "bottom", "left"] as const).map((dir) => { const directionParam = dir === "top" ? "above" : dir; return (
); })} {/* delete button */}
); } /** * Render the inner content of the `#layout-grid` container — either the empty * placeholder or the full builder grid with all cells. This is what htmx swaps * in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize. */ export function renderGrid( layoutId: number, cells: LayoutCell[], entities: Entity[], cameras: Camera[], ): string { if (cells.length === 0) { return (
); } // Compute grid dimensions. let gridCols = 1; let gridRows = 1; for (const c of cells) { const right = c.col + c.col_span; const bottom = c.row + c.row_span; if (right > gridCols) gridCols = right; if (bottom > gridRows) gridRows = bottom; } return ( <>
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}

Linked Accounts

{props.accounts.length === 0 ? (

No cloud accounts linked yet.

) : (
{props.accounts.map((a) => { const vendorLabel = props.vendors.find((v) => v.value === a.vendor)?.label ?? a.vendor; return ( ); })}
Name Vendor Cameras Last Sync
{a.name} {vendorLabel} {String(a.camera_count)} {a.last_sync_at ? formatTime(a.last_sync_at) : "—"} {a.last_sync_error && (
{a.last_sync_error}
)}
)} ); } export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) { const k = props.kiosk; const current = k.os_version ?? "unknown"; return (

OS

Running: {current}
{k.os_update_last_attempt_version && (
Last attempt: {k.os_update_last_attempt_version} {k.os_update_last_attempt_at && at {formatTime(k.os_update_last_attempt_at)}} {k.os_update_last_error && — {k.os_update_last_error}}
)}
); }