/** * 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, 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[]; /** 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[]; /** If set, render the content-assignment form for this cell beneath the grid. */ selectedCellId?: number | null; error?: string; success?: string; } 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.selected { background: #dbeafe; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; } .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-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; } `; export function LayoutEditPage(props: LayoutEditPageProps) { const l = props.layout; const cells = props.cells; const cameraById = new Map(); for (const cam of props.cameras) { cameraById.set(cam.id, cam); } // Compute grid dimensions from cells. 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; } const selectedCell = props.selectedCellId ? cells.find((c) => c.id === props.selectedCellId) ?? null : null; function cellLabel(c: LayoutCell): string { 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"; } 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 to see + buttons (add a neighbour) and the × delete button. Click a cell to assign content.

{cells.length === 0 ? (
) : (
{cells.map((c) => { const isSelected = selectedCell?.id === c.id; const cellStyle = `grid-column:${String(c.col + 1)} / span ${String(c.col_span)}; grid-row:${String(c.row + 1)} / span ${String(c.row_span)};`; const isEmpty = c.content_type === "html" && !c.html_content || c.content_type === "camera" && !c.camera_id || c.content_type === "web" && !c.web_url; return ( {isEmpty ? {cellLabel(c)} : {cellLabel(c)} } {/* + buttons (4 sides) */} {(["top", "right", "bottom", "left"] as const).map((dir) => { const directionParam = dir === "top" ? "above" : dir; return (
); })} {/* delete button */}
); })}
)}
{/* Selected-cell content form */} {selectedCell && (

Cell at row {String(selectedCell.row)}, col {String(selectedCell.col)} {" "}({String(selectedCell.row_span)}x{String(selectedCell.col_span)})

Cancel
)}
); } // ---- 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; } }