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

All Layouts

New Layout

A layout defines a grid of regions and binds cameras or other content into them for a display.

{props.layouts.length === 0 ? ( ) : ( props.layouts.map((l) => { const disp = props.displays.get(l.display_id); return ( ); }) )}
Name Grid Display Priority Default
No layouts created yet
{l.name} {String(l.grid_cols)}x{String(l.grid_rows)} ({String(l.regions.length)} regions) {disp ? disp.name : `#${String(l.display_id)}`} {l.priority === "hot" ? hot : l.priority === "cold" ? cold : normal } {l.is_default ? Yes : ""}
); } // ---- Layout New ------------------------------------------------------------- interface LayoutNewPageProps { user: string; displays: Display[]; error?: string; values?: Record; } export function LayoutNewPage(props: LayoutNewPageProps) { const v = props.values ?? {}; return (
{/* Quick presets */}

Quick Create from Preset

Pick a preset grid layout. You can also define a custom grid below.

{[ { preset: "fullscreen", label: "Fullscreen", desc: "1x1 grid, single region" }, { preset: "2x2", label: "2x2 Grid", desc: "4 equal regions" }, { preset: "1plus3", label: "1+3", desc: "Large left, 3 stacked right" }, { preset: "3x3", label: "3x3 Grid", desc: "9 equal regions" }, ].map((p) => (
))}
{/* Full form */}

Custom Layout

{props.displays.length === 0 && (
No displays exist yet. Pair a kiosk first to create a display.
)}
Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed.
Cancel
); } // ---- Layout Edit ------------------------------------------------------------ interface LayoutEditPageProps { user: string; layout: LayoutType; display: Display; cells: LayoutCell[]; cameras: Camera[]; error?: string; success?: string; } export function LayoutEditPage(props: LayoutEditPageProps) { const l = props.layout; // Build a map from region_name → cell for easy lookup const cellByRegion = new Map(); for (const c of props.cells) { cellByRegion.set(c.region_name, c); } // Also build camera name lookup const cameraById = new Map(); for (const cam of props.cameras) { cameraById.set(cam.id, cam); } return (
{/* Settings */}

Settings

How long streams stay warm after leaving this layout. Leave blank for no timeout.
Back
Grid: {String(l.grid_cols)}x{String(l.grid_rows)}, {String(l.regions.length)} region{l.regions.length !== 1 ? "s" : ""}
{/* Grid preview with cell assignments */} {l.regions.length > 0 && (

Grid Preview

{l.regions.map((r) => { const cell = cellByRegion.get(r.name); let label = r.name; let bgColor = "#f9fafb"; let textColor = "#666"; if (cell) { bgColor = "#dbeafe"; textColor = "#1e40af"; if (cell.content_type === "camera" && cell.camera_id) { const cam = cameraById.get(cell.camera_id); label = cam ? cam.name : `cam #${String(cell.camera_id)}`; } else if (cell.content_type === "web") { label = "Web"; } else if (cell.content_type === "html") { label = "HTML"; } } return (
{label}
); })}
)} {/* Regions table */}

Regions

{l.regions.length === 0 ? ( ) : ( l.regions.map((r) => ( )) )}
Region Position Size
No regions defined
{r.name} row {String(r.row)}, col {String(r.col)} {String(r.rowSpan)}x{String(r.colSpan)}
{/* Cell assignments table */}

Cell Assignments

{l.regions.map((r) => { const cell = cellByRegion.get(r.name); return ( ); })}
Region Content Actions
{r.name} {cell ? ( {cell.content_type} {" "} {cell.content_type === "camera" && cell.camera_id ? (cameraById.get(cell.camera_id)?.name ?? `#${String(cell.camera_id)}`) : cell.content_type === "web" && cell.web_url ? {cell.web_url} : cell.content_type === "html" ? (custom HTML) : "" } ) : ( Empty )} {cell && (
)}
{/* Add cell form */}

Assign Content to Region

); } // ---- Display Edit ----------------------------------------------------------- interface DisplayEditPageProps { user: string; display: Display; layouts: 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)}
{d.kiosk_id && ( )}
Layout shown on idle revert.
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
{props.layouts.length > 0 && (

Layouts on This Display

{props.layouts.map((l) => ( ))}
Name Priority Default
{l.name} {l.priority} {l.is_default ? Yes : ""}
)}
); } // ---- 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; } }