` element
* suitable for hx-swap="outerHTML" against itself.
*/
export function renderCell(
layoutId: number,
c: LayoutCell,
cameras: Camera[],
mode: "read" | "edit",
): string {
const cameraById = new Map
();
for (const cam of cameras) cameraById.set(cam.id, cam);
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.
const isEmpty = (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, 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 */}
+W
-W
+H
-H
{/* 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[],
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, 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 */}
{/* 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.cameras)}
Delete Layout
);
}
// ---- 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 && (
)}
Name
Save
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.
) : (
Name
Priority
Default
{props.attachedLayouts.map((l) => (
{l.name}
{l.priority}
{d.default_layout_id === l.id ? Yes : ""}
Detach
))}
)}
{props.availableLayouts.length > 0 ? (
-- Pick a layout to attach --
{props.availableLayouts.map((l) => (
{l.name}
))}
Attach
) : (
{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.
Name
Details
{props.displays.length === 0 ? (
None configured yet
) : (
props.displays.map((d) => (
{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;
}
}