/** * Admin page templates: overview, cameras, kiosks, account, etc. */ import { js } from "jsx-htmx"; import { Layout } from "./layout.js"; import type { Camera, Display, Entity, Kiosk, Label, Layout as LayoutType, LayoutCell, 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; } export function CamerasPage(props: CamerasProps) { return (

All Cameras

Add Camera
{props.cameras.length === 0 ? ( ) : ( props.cameras.map((cam) => ( )) )}
Name Type Streams Status
No cameras configured
{cam.name} {cam.type.toUpperCase()} {String(props.streamCounts.get(cam.id) ?? 0)} {cam.enabled ? Enabled : Disabled }
); } // ---- Camera New ------------------------------------------------------------- interface CameraNewProps { user: string; error?: string; values?: Record; } export function CameraNewPage(props: CameraNewProps) { const v = props.values ?? {}; return (
Discover via ONVIF →
Cancel
); } // ---- Camera ONVIF Discovery ------------------------------------------------ interface CameraDiscoverProps { user: string; 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; role: "main" | "sub" | "other"; } interface DiscoveredCameraRow { name: string; source_token: string | null; profiles: DiscoveredProfileRow[]; } interface CameraDiscoverResultsProps { user: string; host: string; username: string; password: string; cameras: DiscoveredCameraRow[]; error?: string; success?: string; } 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}
Discover Another Back to Cameras
); } 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) => (

{cam.name}

{cam.source_token ?
Source: {cam.source_token}
: ""}
{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("")}
Discover Another Back to Cameras
); } // ---- Entities --------------------------------------------------------------- interface EntitiesPageProps { user: string; entities: Entity[]; } function entityBadge(type: string) { const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : "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)}…` : "—"; return "—"; } export function EntitiesPage(props: EntitiesPageProps) { return (

All Entities

New Entity

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

{props.entities.length === 0 ? ( ) : ( props.entities.map((e) => ( )) )}
Name Type Detail
No entities yet
{e.name} {entityBadge(e.type)} {entityDetail(e)}
); } 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 === "web" && (
)} {e.type === "html" && (
)} Back
); } // ---- 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.
Comma-separated label names.
{props.pendingCodes.length > 0 && (
Pending Codes
{props.pendingCodes.map((pc) => (
{pc.code} {formatTime(pc.expires_at)}
))}
)}
); } // ---- 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 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 }>; error?: string; success?: string; } export function CameraEditPage(props: CameraEditProps) { const cam = props.camera; return (

Edit Camera

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

Labels

{props.labels.length > 0 ? (
{props.labels.map((l) => (
))}
) : (

No labels attached

)}

Streams

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

No streams configured

)}
); } // ---- Kiosk Edit ------------------------------------------------------------- interface KioskEditProps { user: string; kiosk: Kiosk; labels: Array<{ label_id: number; name: string; role: string }>; allLabels: Label[]; displays?: Display[]; error?: string; success?: string; } 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 (CEC)
{/* Associated displays */}

Displays

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

No displays associated with this kiosk

)}

Labels

{props.labels.length > 0 ? (
{props.labels.map((l) => (
))}
) : (

No labels attached

)}
); } // ---- 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: hidden; } .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: #999; font-size: 0.75rem; font-weight: 400; } .layout-cell-add { position: absolute; background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; opacity: 0; transition: opacity 0.2s; padding: 0; z-index: 2; } .layout-cell:hover .layout-cell-add { opacity: 1; } .layout-cell-add:hover { background: #1e40af; opacity: 1; } .layout-cell-add-top { top: -12px; left: 50%; transform: translateX(-50%); } .layout-cell-add-right { right: -12px; top: 50%; transform: translateY(-50%); } .layout-cell-add-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); } .layout-cell-add-left { left: -12px; top: 50%; transform: translateY(-50%); } .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-resize { position: absolute; bottom: 4px; right: 4px; display: flex; gap: 2px; opacity: 0; transition: opacity 0.2s; z-index: 2; } .layout-cell:hover .layout-cell-resize { opacity: 1; } .layout-cell-resize button { background: rgba(37, 99, 235, 0.85); color: #fff; border: none; min-width: 24px; height: 18px; border-radius: 3px; cursor: pointer; font-size: 0.65rem; line-height: 1; padding: 0 4px; font-weight: 700; } .layout-cell-resize button:hover { background: #1e40af; } .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 === "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 (
); } // 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 === "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 ? {label} : {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 ( ); })} {/* resize buttons */}
{/* 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"))}
); } export function LayoutEditPage(props: LayoutEditPageProps) { const l = props.layout; const cells = props.cells; // Compute grid dimensions from cells (for summary text). 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 (
{/* Settings */}

Settings

How long streams stay warm after leaving this layout. Leave blank for no timeout.
Back
Grid: {String(gridCols)}x{String(gridRows)}, {String(cells.length)} cell{cells.length !== 1 ? "s" : ""}
{props.displays.length === 0 ? Attached to no displays — attach from a display's edit page. : ( Attached to:{" "} {props.displays.map((d, i) => ( {i > 0 ? ", " : ""} {d.name} ))} )}
{/* Visual builder */}

Layout Builder

Hover a cell for + (add neighbour), resize handles (+W -W +H -H) and × (delete). Click a cell to edit content in-place.

{renderGrid(l.id, cells, props.entities, props.cameras)}
); } // ---- Display Edit ----------------------------------------------------------- interface DisplayEditPageProps { user: string; display: Display; /** Layouts currently attached to this display. */ attachedLayouts: LayoutType[]; /** All other layouts that could be attached. */ availableLayouts: LayoutType[]; kioskName?: string | null; error?: string; success?: string; } export function DisplayEditPage(props: DisplayEditPageProps) { const d = props.display; return (

Display Info

Index: {String(d.index)}
Resolution: {String(d.width_px)}x{String(d.height_px)} (reported by kiosk)
{d.kiosk_id && ( )}
Layout shown on idle revert. Only layouts attached below are eligible.
Revert to default layout after this many seconds of inactivity. 0 to disable.
Send CEC standby after this many seconds of inactivity. 0 to disable.
Back
{/* Layout attachments */}

Available Layouts

Pick which layouts this display can show. The kiosk receives only attached layouts in its bundle.

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

No layouts attached yet.

) : (
{props.attachedLayouts.map((l) => ( ))}
Name Priority Default
{l.name} {l.priority} {d.default_layout_id === l.id ? Yes : ""}
)} {props.availableLayouts.length > 0 ? (
) : (

{props.attachedLayouts.length === 0 ? No layouts exist yet. Create one. : "All existing layouts are already attached."}

)}
); } // ---- Displays List (with clickable links) ----------------------------------- interface DisplaysPageProps { user: string; displays: Display[]; } export function DisplaysPage(props: DisplaysPageProps) { return (

Physical HDMI displays. Created automatically when kiosks are paired.

{props.displays.length === 0 ? ( ) : ( props.displays.map((d) => ( )) )}
Name Details
None configured yet
{d.name} {String(d.width_px)}x{String(d.height_px)} — index {String(d.index)}
); } // ---- Helpers ---------------------------------------------------------------- function parseRtspUrl(url: string): { host: string; port: string; path: string; username: string; password: string } { const m = url.match(/^rtsp:\/\/(?:([^:@]+)(?::([^@]*))?@)?([^:/]+)(?::(\d+))?(\/.*)?$/); if (!m) return { host: "", port: "554", path: "", username: "", password: "" }; return { username: decodeURIComponent(m[1] ?? ""), password: decodeURIComponent(m[2] ?? ""), host: m[3] ?? "", port: m[4] ?? "554", path: m[5] ?? "", }; } function formatTime(iso: string): string { try { const d = new Date(iso); return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } }