feat: layout/template/display CRUD + display-chain bundle routing

Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
  (no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
  BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
  to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
  updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
  updateDisplay, and more
This commit is contained in:
Mitchell R 2026-05-10 03:45:53 +02:00
parent 7b4a11c182
commit cc306cec57
No known key found for this signature in database
6 changed files with 1455 additions and 97 deletions

View file

@ -13,8 +13,16 @@ import {
KiosksPage, KiosksPage,
KioskEditPage, KioskEditPage,
LabelsPage, LabelsPage,
SimpleListPage, TemplatesPage,
TemplateNewPage,
TemplateEditPage,
LayoutsPage,
LayoutNewPage,
LayoutEditPage,
DisplaysPage,
DisplayEditPage,
} from "../../web-templates/admin-pages.js"; } from "../../web-templates/admin-pages.js";
import type { LayoutTemplate, Display } from "../../shared/types.js";
export function registerAdminRoutes(app: H3, deps: AdminDeps): void { export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// ---- Overview ------------------------------------------------------------- // ---- Overview -------------------------------------------------------------
@ -167,43 +175,305 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
}); });
// ---- Simple list pages (templates, layouts, displays, labels) ------------- // ---- Templates (Layout Templates) ------------------------------------------
app.get("/admin/templates", (event) => { app.get("/admin/templates", (event) => {
const user = event.context.user!; const user = event.context.user!;
return htmlPage(SimpleListPage({ const templates = deps.repo.listLayoutTemplates();
user: user.username, return htmlPage(TemplatesPage({ user: user.username, templates }));
pageTitle: "Layout Templates",
description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.",
activeNav: "templates",
items: [], // TODO: list templates
}));
}); });
app.get("/admin/templates/new", (event) => {
const user = event.context.user!;
return htmlPage(TemplateNewPage({ user: user.username }));
});
app.post("/admin/templates/new", async (event) => {
const user = event.context.user!;
const body = await readBody<Record<string, string>>(event);
const preset = body?.["preset"] ?? "custom";
let name = (body?.["name"] ?? "").trim();
const errors: string[] = [];
if (!name || name.length > 128) {
errors.push("Name required (max 128 chars).");
}
let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }> = [];
let gridCols = 12;
let gridRows = 12;
if (preset === "fullscreen") {
gridCols = 1;
gridRows = 1;
regions = [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }];
} else if (preset === "2x2") {
gridCols = 2;
gridRows = 2;
regions = [
{ name: "tl", row: 0, col: 0, rowSpan: 1, colSpan: 1 },
{ name: "tr", row: 0, col: 1, rowSpan: 1, colSpan: 1 },
{ name: "bl", row: 1, col: 0, rowSpan: 1, colSpan: 1 },
{ name: "br", row: 1, col: 1, rowSpan: 1, colSpan: 1 },
];
} else if (preset === "1plus3") {
gridCols = 2;
gridRows = 3;
regions = [
{ name: "main", row: 0, col: 0, rowSpan: 3, colSpan: 1 },
{ name: "r1", row: 0, col: 1, rowSpan: 1, colSpan: 1 },
{ name: "r2", row: 1, col: 1, rowSpan: 1, colSpan: 1 },
{ name: "r3", row: 2, col: 1, rowSpan: 1, colSpan: 1 },
];
} else if (preset === "3x3") {
gridCols = 3;
gridRows = 3;
regions = [
{ name: "r1", row: 0, col: 0, rowSpan: 1, colSpan: 1 },
{ name: "r2", row: 0, col: 1, rowSpan: 1, colSpan: 1 },
{ name: "r3", row: 0, col: 2, rowSpan: 1, colSpan: 1 },
{ name: "r4", row: 1, col: 0, rowSpan: 1, colSpan: 1 },
{ name: "r5", row: 1, col: 1, rowSpan: 1, colSpan: 1 },
{ name: "r6", row: 1, col: 2, rowSpan: 1, colSpan: 1 },
{ name: "r7", row: 2, col: 0, rowSpan: 1, colSpan: 1 },
{ name: "r8", row: 2, col: 1, rowSpan: 1, colSpan: 1 },
{ name: "r9", row: 2, col: 2, rowSpan: 1, colSpan: 1 },
];
} else {
// Custom
gridCols = parseInt(body?.["grid_cols"] ?? "12", 10);
gridRows = parseInt(body?.["grid_rows"] ?? "12", 10);
if (isNaN(gridCols) || gridCols < 1 || gridCols > 12) errors.push("Grid columns must be 1-12.");
if (isNaN(gridRows) || gridRows < 1 || gridRows > 12) errors.push("Grid rows must be 1-12.");
const regionsStr = (body?.["regions"] ?? "").trim();
if (!regionsStr) {
errors.push("Regions JSON is required for custom templates.");
} else {
try {
regions = JSON.parse(regionsStr);
if (!Array.isArray(regions) || regions.length === 0) {
errors.push("Regions must be a non-empty JSON array.");
}
} catch {
errors.push("Invalid JSON in regions field.");
}
}
}
if (errors.length > 0) {
return htmlPage(TemplateNewPage({
user: user.username,
error: errors.join(" "),
values: body,
}));
}
deps.repo.createLayoutTemplate({
name,
regions,
grid_cols: gridCols,
grid_rows: gridRows,
});
return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
});
app.get("/admin/templates/:id", (event) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const template = deps.repo.getLayoutTemplateById(id);
if (!template) return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
return htmlPage(TemplateEditPage({ user: user.username, template }));
});
app.post("/admin/templates/:id", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
deps.repo.updateLayoutTemplate(id, {
name: body?.["name"],
description: body?.["description"] || null,
});
return new Response(null, { status: 302, headers: { location: `/admin/templates/${id}` } });
});
app.post("/admin/templates/:id/delete", (event) => {
const id = Number(getRouterParam(event, "id"));
deps.repo.deleteLayoutTemplate(id);
return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
});
// ---- Layouts ---------------------------------------------------------------
app.get("/admin/layouts", (event) => { app.get("/admin/layouts", (event) => {
const user = event.context.user!; const user = event.context.user!;
return htmlPage(SimpleListPage({ const layouts = deps.repo.listLayouts();
const templateIds = [...new Set(layouts.map((l) => l.template_id))];
const displayIds = [...new Set(layouts.map((l) => l.display_id))];
const templates = new Map<number, LayoutTemplate>();
for (const tid of templateIds) {
const t = deps.repo.getLayoutTemplateById(tid);
if (t) templates.set(tid, t);
}
const displays = new Map<number, Display>();
for (const did of displayIds) {
const d = deps.repo.getDisplayById(did);
if (d) displays.set(did, d);
}
return htmlPage(LayoutsPage({ user: user.username, layouts, templates, displays }));
});
app.get("/admin/layouts/new", (event) => {
const user = event.context.user!;
return htmlPage(LayoutNewPage({
user: user.username, user: user.username,
pageTitle: "Layouts", templates: deps.repo.listLayoutTemplates(),
description: "A layout binds cameras and other content into a template's regions for one display.", displays: deps.repo.listDisplays(),
activeNav: "layouts",
items: [], // TODO: list layouts
})); }));
}); });
app.post("/admin/layouts/new", async (event) => {
const user = event.context.user!;
const body = await readBody<Record<string, string>>(event);
const name = (body?.["name"] ?? "").trim();
const templateId = parseInt(body?.["template_id"] ?? "", 10);
const displayId = parseInt(body?.["display_id"] ?? "", 10);
const priority = body?.["priority"] ?? "normal";
const description = (body?.["description"] ?? "").trim() || null;
const isDefault = body?.["is_default"] === "1";
const resetsIdleTimer = body?.["resets_idle_timer"] === "1";
const errors: string[] = [];
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
if (isNaN(templateId)) errors.push("Select a template.");
if (isNaN(displayId)) errors.push("Select a display.");
if (errors.length > 0) {
return htmlPage(LayoutNewPage({
user: user.username,
templates: deps.repo.listLayoutTemplates(),
displays: deps.repo.listDisplays(),
error: errors.join(" "),
values: body,
}));
}
const layout = deps.repo.createLayout({
name,
description,
template_id: templateId,
display_id: displayId,
priority,
is_default: isDefault,
resets_idle_timer: resetsIdleTimer,
});
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layout.id}` } });
});
app.get("/admin/layouts/:id", (event) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const layout = deps.repo.getLayoutById(id);
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
const template = deps.repo.getLayoutTemplateById(layout.template_id);
if (!template) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
const display = deps.repo.getDisplayById(layout.display_id);
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
const cells = deps.repo.layoutCells(id);
const cameras = deps.repo.listCameras();
return htmlPage(LayoutEditPage({
user: user.username,
layout,
template,
display,
cells,
cameras,
}));
});
app.post("/admin/layouts/:id", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10);
deps.repo.updateLayout(id, {
name: body?.["name"],
description: body?.["description"] || null,
priority: (body?.["priority"] ?? "normal") as any,
cooling_timeout_seconds: coolingTimeout,
is_default: body?.["is_default"] === "1",
resets_idle_timer: body?.["resets_idle_timer"] === "1",
});
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${id}` } });
});
app.post("/admin/layouts/:id/cells", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
const regionName = (body?.["region_name"] ?? "").trim();
const contentType = body?.["content_type"] ?? "camera";
if (!regionName) {
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
}
deps.repo.createLayoutCell({
layout_id: layoutId,
region_name: regionName,
content_type: contentType,
camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null,
stream_selector: contentType === "camera" ? (body?.["stream_selector"] ?? "auto") : null,
web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null,
html_content: contentType === "html" ? (body?.["html_content"] ?? null) : null,
});
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
app.post("/admin/layouts/:id/cells/:cellId/delete", (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
deps.repo.deleteLayoutCell(cellId);
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
app.post("/admin/layouts/:id/delete", (event) => {
const id = Number(getRouterParam(event, "id"));
deps.repo.deleteLayout(id);
return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
});
// ---- Displays --------------------------------------------------------------
app.get("/admin/displays", (event) => { app.get("/admin/displays", (event) => {
const user = event.context.user!; const user = event.context.user!;
const displays = deps.repo.listDisplays(); const displays = deps.repo.listDisplays();
return htmlPage(SimpleListPage({ return htmlPage(DisplaysPage({ user: user.username, displays }));
user: user.username, });
pageTitle: "Displays",
description: "Physical HDMI displays. Primary display created during setup.", app.get("/admin/displays/:id", (event) => {
activeNav: "displays", const user = event.context.user!;
items: displays.map((d) => ({ const id = Number(getRouterParam(event, "id"));
name: d.name, const display = deps.repo.getDisplayById(id);
detail: `${d.width_px}x${d.height_px} — index ${d.index}`, if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
})), const layouts = deps.repo.layoutsForDisplay(id);
})); return htmlPage(DisplayEditPage({ user: user.username, display, layouts }));
});
app.post("/admin/displays/:id", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
const defaultLayoutId = body?.["default_layout_id"] ? Number(body["default_layout_id"]) : null;
deps.repo.updateDisplay(id, {
name: body?.["name"],
default_layout_id: defaultLayoutId,
idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10),
sleep_timeout_seconds: parseInt(body?.["sleep_timeout_seconds"] ?? "0", 10),
width_px: body?.["width_px"] ? parseInt(body["width_px"], 10) : undefined,
height_px: body?.["height_px"] ? parseInt(body["height_px"], 10) : undefined,
} as any);
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } });
}); });
app.get("/admin/labels", (event) => { app.get("/admin/labels", (event) => {

View file

@ -45,7 +45,30 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
deps.repo.markClusterKeyProvisioned(); deps.repo.markClusterKeyProvisioned();
deps.repo.createDefaultDisplay(); // Create default display, template, and layout
const display = deps.repo.createDefaultDisplay();
const template = deps.repo.createLayoutTemplate({
name: "Fullscreen",
description: "Single region covering the entire display",
regions: [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }],
grid_cols: 1,
grid_rows: 1,
is_builtin: true,
});
const layout = deps.repo.createLayout({
name: "Default",
description: "Default layout — BetterFrame logo",
template_id: template.id,
display_id: display.id,
is_default: true,
});
deps.repo.createLayoutCell({
layout_id: layout.id,
region_name: "main",
content_type: "html",
html_content: '<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#1a1a2e;color:#fff;font-family:system-ui"><h1 style="font-size:3rem;font-weight:300">BetterFrame</h1></div>',
});
deps.repo.updateDisplay(display.id, { default_layout_id: layout.id });
deps.repo.markSetupComplete(); deps.repo.markSetupComplete();
return new Response(null, { return new Response(null, {

View file

@ -359,6 +359,238 @@ export class Repository {
return d; return d;
} }
updateDisplay(id: number, patch: Partial<Display>): void {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id") continue;
const col = k === "index" ? `"index"` : k;
sets.push(`${col} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
this.db.prepare(`UPDATE displays SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
void this.notify("displays", "update", id);
}
// ===========================================================================
// layout templates
// ===========================================================================
listLayoutTemplates(): LayoutTemplate[] {
const rs = this.prep("SELECT * FROM layout_templates ORDER BY name").all();
return rs.map((r) => rowToLayoutTemplate(r as Record<string, unknown>));
}
getLayoutTemplateById(id: number): LayoutTemplate | null {
const r = this.prep("SELECT * FROM layout_templates WHERE id = ?").get(id);
return r ? rowToLayoutTemplate(r as Record<string, unknown>) : null;
}
createLayoutTemplate(input: {
name: string;
description?: string | null;
regions: unknown;
grid_cols?: number;
grid_rows?: number;
is_builtin?: boolean;
}): LayoutTemplate {
const result = this.prep(
`INSERT INTO layout_templates (name, description, regions, grid_cols, grid_rows, is_builtin)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
input.name,
input.description ?? null,
J(input.regions),
input.grid_cols ?? 12,
input.grid_rows ?? 12,
B(input.is_builtin ?? false),
);
const id = Number(result.lastInsertRowid);
void this.notify("layout_templates", "create", id);
const r = this.getLayoutTemplateById(id);
if (!r) throw new Error("layout_template vanished after insert");
return r;
}
updateLayoutTemplate(id: number, patch: { name?: string; description?: string | null; regions?: unknown; grid_cols?: number; grid_rows?: number }): void {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id") continue;
sets.push(`${k} = ?`);
vals.push(k === "regions" ? J(v) : (v === undefined ? null : v));
}
if (sets.length === 0) return;
vals.push(id);
this.db.prepare(`UPDATE layout_templates SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
void this.notify("layout_templates", "update", id);
}
deleteLayoutTemplate(id: number): void {
this.db.prepare(`DELETE FROM layout_templates WHERE id = ?`).run(id);
void this.notify("layout_templates", "delete", id);
}
// ===========================================================================
// layouts
// ===========================================================================
listLayouts(): Layout[] {
const rs = this.prep("SELECT * FROM layouts ORDER BY name").all();
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
}
getLayoutById(id: number): Layout | null {
const r = this.prep("SELECT * FROM layouts WHERE id = ?").get(id);
return r ? rowToLayout(r as Record<string, unknown>) : null;
}
layoutsForDisplay(displayId: number): Layout[] {
const rs = this.prep(
"SELECT * FROM layouts WHERE display_id = ? ORDER BY name",
).all(displayId);
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
}
createLayout(input: {
name: string;
description?: string | null;
template_id: number;
display_id: number;
priority?: string;
cooling_timeout_seconds?: number | null;
preload_camera_ids?: number[];
is_default?: boolean;
resets_idle_timer?: boolean;
}): Layout {
const result = this.prep(
`INSERT INTO layouts (name, description, template_id, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.name,
input.description ?? null,
input.template_id,
input.display_id,
input.priority ?? "normal",
input.cooling_timeout_seconds ?? null,
J(input.preload_camera_ids ?? []),
B(input.is_default ?? false),
B(input.resets_idle_timer ?? true),
);
const id = Number(result.lastInsertRowid);
void this.notify("layouts", "create", id);
const r = this.getLayoutById(id);
if (!r) throw new Error("layout vanished after insert");
return r;
}
updateLayout(id: number, patch: Partial<Layout>): void {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id") continue;
sets.push(`${k} = ?`);
if (k === "preload_camera_ids") vals.push(J(v));
else if (typeof v === "boolean") vals.push(B(v));
else vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
this.db.prepare(`UPDATE layouts SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
void this.notify("layouts", "update", id);
}
deleteLayout(id: number): void {
this.db.prepare(`DELETE FROM layout_cells WHERE layout_id = ?`).run(id);
this.db.prepare(`DELETE FROM layout_labels WHERE layout_id = ?`).run(id);
this.db.prepare(`DELETE FROM layouts WHERE id = ?`).run(id);
void this.notify("layouts", "delete", id);
}
// ===========================================================================
// layout cells
// ===========================================================================
createLayoutCell(input: {
layout_id: number;
region_name: string;
content_type: string;
camera_id?: number | null;
stream_selector?: string | null;
web_url?: string | null;
html_content?: string | null;
cooling_timeout_seconds?: number | null;
options?: Record<string, unknown>;
}): LayoutCell {
const result = this.prep(
`INSERT INTO layout_cells (layout_id, region_name, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.layout_id,
input.region_name,
input.content_type,
input.camera_id ?? null,
input.stream_selector ?? null,
input.web_url ?? null,
input.html_content ?? null,
input.cooling_timeout_seconds ?? null,
J(input.options ?? {}),
);
const id = Number(result.lastInsertRowid);
void this.notify("layout_cells", "create", id);
const r = this.prep("SELECT * FROM layout_cells WHERE id = ?").get(id);
if (!r) throw new Error("layout_cell vanished after insert");
return rowToLayoutCell(r as Record<string, unknown>);
}
updateLayoutCell(id: number, patch: Partial<LayoutCell>): void {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "layout_id") continue;
sets.push(`${k} = ?`);
if (k === "options") vals.push(J(v));
else vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
this.db.prepare(`UPDATE layout_cells SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
void this.notify("layout_cells", "update", id);
}
deleteLayoutCell(id: number): void {
this.db.prepare(`DELETE FROM layout_cells WHERE id = ?`).run(id);
void this.notify("layout_cells", "delete", id);
}
// ===========================================================================
// display-chain bundle queries (kiosk → display → layouts → cells → cameras)
// ===========================================================================
layoutsForDisplayId(displayId: number): Layout[] {
const rs = this.prep(
"SELECT * FROM layouts WHERE display_id = ? ORDER BY is_default DESC, name",
).all(displayId);
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
}
camerasForLayoutIds(layoutIds: number[]): Camera[] {
if (layoutIds.length === 0) return [];
const placeholders = layoutIds.map(() => "?").join(",");
const rs = this.db
.prepare(
`SELECT DISTINCT c.* FROM cameras c
JOIN layout_cells lc ON lc.camera_id = c.id
WHERE lc.layout_id IN (${placeholders})
AND c.enabled = 1
ORDER BY c.name`,
)
.all(...(layoutIds as never[]));
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
}
// =========================================================================== // ===========================================================================
// cameras // cameras
// =========================================================================== // ===========================================================================
@ -564,17 +796,19 @@ export class Repository {
key_prefix: string; key_prefix: string;
capabilities?: string[]; capabilities?: string[];
hardware_model?: string | null; hardware_model?: string | null;
display_id?: number | null;
}): Kiosk { }): Kiosk {
const result = this.prep( const result = this.prep(
`INSERT INTO kiosks `INSERT INTO kiosks
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at) (name, key_hash, key_prefix, capabilities, hardware_model, display_id, paired_at)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
input.name, input.name,
input.key_hash, input.key_hash,
input.key_prefix, input.key_prefix,
J(input.capabilities ?? []), J(input.capabilities ?? []),
input.hardware_model ?? null, input.hardware_model ?? null,
input.display_id ?? null,
isoNow(), isoNow(),
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);

View file

@ -1,8 +1,8 @@
/** /**
* Label-scoped bundle generation shared module. * Bundle generation display-chain routing.
* *
* Queries cameras/layouts/templates for a kiosk's label set, * kiosk.display_id layouts for display cells cameras
* encrypts ONVIF passwords with cluster key, returns versioned bundle. * No label filtering for v0.1.
*/ */
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { Repository } from "../plugins/service-store/repository.js"; import type { Repository } from "../plugins/service-store/repository.js";
@ -17,6 +17,7 @@ export interface BundleCamera {
onvif_port: number | null; onvif_port: number | null;
onvif_username: string | null; onvif_username: string | null;
onvif_password_encrypted: string | null; onvif_password_encrypted: string | null;
stream_policy: string;
streams: Array<{ streams: Array<{
id: number; id: number;
role: string; role: string;
@ -29,40 +30,50 @@ export interface BundleCamera {
}>; }>;
} }
export interface BundleCell {
region_name: string;
content_type: string;
camera_id: number | null;
stream_selector: string | null;
web_url: string | null;
html_content: string | null;
cooling_timeout_seconds: number | null;
}
export interface BundleLayout { export interface BundleLayout {
id: number; id: number;
name: string; name: string;
template_id: number | null; template: {
display_id: number | null;
priority: string;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
is_default: boolean;
resets_idle_timer: boolean;
cells: Array<{
region_name: string;
content_type: string;
camera_id: number | null;
stream_selector: string | null;
web_url: string | null;
html_content: string | null;
}>;
}
export interface KioskBundle {
kiosk_id: number;
kiosk_name: string;
labels: string[];
operate_labels: string[];
cameras: BundleCamera[];
layouts: BundleLayout[];
templates: Array<{
id: number; id: number;
name: string; name: string;
regions: unknown; regions: unknown;
grid_cols: number; grid_cols: number;
grid_rows: number; grid_rows: number;
}>; } | null;
priority: string;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
is_default: boolean;
resets_idle_timer: boolean;
cells: BundleCell[];
}
export interface BundleDisplay {
id: number;
name: string;
width_px: number;
height_px: number;
idle_timeout_seconds: number;
sleep_timeout_seconds: number;
default_layout_id: number | null;
}
export interface KioskBundle {
kiosk_id: number;
kiosk_name: string;
display: BundleDisplay;
layouts: BundleLayout[];
cameras: BundleCamera[];
version: string; version: string;
} }
@ -73,11 +84,46 @@ export function generateBundle(
clusterKey: string | undefined, clusterKey: string | undefined,
): KioskBundle | null { ): KioskBundle | null {
const kiosk = repo.getKioskById(kioskId); const kiosk = repo.getKioskById(kioskId);
if (!kiosk) return null; if (!kiosk || !kiosk.display_id) return null;
const scope = repo.bundleScope(kioskId); const display = repo.getDisplayById(kiosk.display_id);
const cameras = repo.camerasForLabelIds(scope.labelIds); if (!display) return null;
const layouts = repo.layoutsForLabelIds(scope.labelIds);
const layouts = repo.layoutsForDisplayId(display.id);
const layoutIds = layouts.map((l) => l.id);
// Collect all cameras referenced by cells in these layouts
const cameras = repo.camerasForLayoutIds(layoutIds);
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
const cells = repo.layoutCells(l.id);
const template = l.template_id ? repo.getLayoutTemplateById(l.template_id) : null;
return {
id: l.id,
name: l.name,
template: template ? {
id: template.id,
name: template.name,
regions: template.regions,
grid_cols: template.grid_cols,
grid_rows: template.grid_rows,
} : null,
priority: l.priority,
cooling_timeout_seconds: l.cooling_timeout_seconds,
preload_camera_ids: l.preload_camera_ids,
is_default: l.is_default,
resets_idle_timer: l.resets_idle_timer,
cells: cells.map((c) => ({
region_name: c.region_name,
content_type: c.content_type,
camera_id: c.camera_id,
stream_selector: c.stream_selector,
web_url: c.web_url,
html_content: c.html_content,
cooling_timeout_seconds: c.cooling_timeout_seconds,
})),
};
});
const bundleCameras: BundleCamera[] = cameras.map((cam) => { const bundleCameras: BundleCamera[] = cameras.map((cam) => {
const streams = repo.listCameraStreams(cam.id); const streams = repo.listCameraStreams(cam.id);
@ -94,6 +140,7 @@ export function generateBundle(
onvif_port: cam.onvif_port, onvif_port: cam.onvif_port,
onvif_username: cam.onvif_username, onvif_username: cam.onvif_username,
onvif_password_encrypted: onvifPwEncrypted, onvif_password_encrypted: onvifPwEncrypted,
stream_policy: cam.stream_policy,
streams: streams.map((s) => ({ streams: streams.map((s) => ({
id: s.id, id: s.id,
role: s.role, role: s.role,
@ -107,46 +154,20 @@ export function generateBundle(
}; };
}); });
const templateIds = [...new Set(layouts.map((l) => l.template_id).filter((id): id is number => id !== null))];
const templates = templateIds.length > 0 ? repo.layoutTemplates(templateIds) : [];
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
const cells = repo.layoutCells(l.id);
return {
id: l.id,
name: l.name,
template_id: l.template_id,
display_id: l.display_id,
priority: l.priority,
cooling_timeout_seconds: l.cooling_timeout_seconds,
preload_camera_ids: l.preload_camera_ids,
is_default: l.is_default,
resets_idle_timer: l.resets_idle_timer,
cells: cells.map((c) => ({
region_name: c.region_name,
content_type: c.content_type,
camera_id: c.camera_id,
stream_selector: c.stream_selector,
web_url: c.web_url,
html_content: c.html_content,
})),
};
});
const bundle: KioskBundle = { const bundle: KioskBundle = {
kiosk_id: kioskId, kiosk_id: kioskId,
kiosk_name: kiosk.name, kiosk_name: kiosk.name,
labels: scope.labelNames, display: {
operate_labels: scope.operateLabelNames, id: display.id,
cameras: bundleCameras, name: display.name,
width_px: display.width_px,
height_px: display.height_px,
idle_timeout_seconds: display.idle_timeout_seconds,
sleep_timeout_seconds: display.sleep_timeout_seconds,
default_layout_id: display.default_layout_id,
},
layouts: bundleLayouts, layouts: bundleLayouts,
templates: templates.map((t) => ({ cameras: bundleCameras,
id: t.id,
name: t.name,
regions: t.regions,
grid_cols: t.grid_cols,
grid_rows: t.grid_rows,
})),
version: "", version: "",
}; };

View file

@ -123,12 +123,17 @@ export async function confirmPairing(
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
// Auto-assign to primary display
const displays = repo.listDisplays();
const primaryDisplay = displays.find((d) => d.is_primary) ?? displays[0];
const kiosk = repo.createKiosk({ const kiosk = repo.createKiosk({
name: kioskName, name: kioskName,
key_hash: kioskKeyHash, key_hash: kioskKeyHash,
key_prefix: kioskKeyPrefix, key_prefix: kioskKeyPrefix,
capabilities: pc.kiosk_capabilities, capabilities: pc.kiosk_capabilities,
hardware_model: pc.kiosk_hardware_model, hardware_model: pc.kiosk_hardware_model,
display_id: primaryDisplay?.id ?? null,
}); });
// Attach initial labels // Attach initial labels

View file

@ -3,7 +3,18 @@
*/ */
import { js } from "jsx-htmx"; import { js } from "jsx-htmx";
import { Layout } from "./layout.js"; import { Layout } from "./layout.js";
import type { Camera, Kiosk, Label, PairingCode, EventLog } from "../shared/types.js"; import type {
Camera,
Display,
Kiosk,
Label,
Layout as LayoutType,
LayoutCell,
LayoutRegion,
LayoutTemplate,
PairingCode,
EventLog,
} from "../shared/types.js";
// ---- Overview --------------------------------------------------------------- // ---- Overview ---------------------------------------------------------------
@ -800,6 +811,800 @@ export function LabelsPage(props: LabelsPageProps) {
); );
} }
// ---- Templates (Layout Templates) -------------------------------------------
interface TemplatesPageProps {
user: string;
templates: LayoutTemplate[];
}
export function TemplatesPage(props: TemplatesPageProps) {
return (
<Layout title="Layout Templates" user={props.user} activeNav="templates">
<div class="section-header">
<h2 class="section-title">All Templates</h2>
<a href="/admin/templates/new" class="btn btn-primary">New Template</a>
</div>
<p style="color:#666; margin-bottom:1.25rem">
Templates define named regions on a grid. Layouts bind content into these regions.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Grid</th>
<th>Regions</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{props.templates.length === 0 ? (
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No templates created yet</td></tr>
) : (
props.templates.map((t) => (
<tr>
<td><a href={`/admin/templates/${t.id}`}><strong>{t.name}</strong></a></td>
<td>{String(t.grid_cols)}x{String(t.grid_rows)}</td>
<td>{String(t.regions.length)} region{t.regions.length !== 1 ? "s" : ""}</td>
<td>
{t.is_builtin
? <span class="badge badge-gray">Built-in</span>
: <span class="badge badge-blue">Custom</span>
}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
// ---- Template New -----------------------------------------------------------
interface TemplateNewPageProps {
user: string;
error?: string;
values?: Record<string, string>;
}
export function TemplateNewPage(props: TemplateNewPageProps) {
const v = props.values ?? {};
return (
<Layout
title="New Template"
user={props.user}
activeNav="templates"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div style="max-width:700px">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Choose a Preset</h2>
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
Pick a preset to get started quickly, or choose Custom to define your own regions.
</p>
<div class="stats-grid" style="margin-bottom:0">
<form method="post" action="/admin/templates/new" style="margin:0">
<input type="hidden" name="preset" value="fullscreen" />
<input type="hidden" name="name" value="Fullscreen" />
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
<strong>Fullscreen</strong>
<div style="color:#666; font-size:0.8rem">1x1 grid, single region</div>
</button>
</form>
<form method="post" action="/admin/templates/new" style="margin:0">
<input type="hidden" name="preset" value="2x2" />
<input type="hidden" name="name" value="2x2 Grid" />
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
<strong>2x2 Grid</strong>
<div style="color:#666; font-size:0.8rem">4 equal regions</div>
</button>
</form>
<form method="post" action="/admin/templates/new" style="margin:0">
<input type="hidden" name="preset" value="1plus3" />
<input type="hidden" name="name" value="1+3" />
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
<strong>1+3</strong>
<div style="color:#666; font-size:0.8rem">Large left, 3 stacked right</div>
</button>
</form>
<form method="post" action="/admin/templates/new" style="margin:0">
<input type="hidden" name="preset" value="3x3" />
<input type="hidden" name="name" value="3x3 Grid" />
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
<strong>3x3 Grid</strong>
<div style="color:#666; font-size:0.8rem">9 equal regions</div>
</button>
</form>
</div>
</div>
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Custom Template</h2>
<form method="post" action="/admin/templates/new">
<input type="hidden" name="preset" value="custom" />
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
</div>
<div class="form-group">
<label for="grid_cols">Grid Columns</label>
<input id="grid_cols" name="grid_cols" type="number" class="form-input" min="1" max="12" value={v["grid_cols"] ?? "12"} />
</div>
<div class="form-group">
<label for="grid_rows">Grid Rows</label>
<input id="grid_rows" name="grid_rows" type="number" class="form-input" min="1" max="12" value={v["grid_rows"] ?? "12"} />
</div>
<div class="form-group">
<label for="regions">Regions (JSON)</label>
<textarea
id="regions"
name="regions"
class="form-input"
rows="6"
placeholder={'[\n { "name": "main", "row": 0, "col": 0, "rowSpan": 12, "colSpan": 12 }\n]'}
>{v["regions"] ?? ""}</textarea>
<div class="form-hint">
Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed.
</div>
</div>
<button type="submit" class="btn btn-primary">Create Template</button>
<a href="/admin/templates" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
</div>
</Layout>
);
}
// ---- Template Edit ----------------------------------------------------------
interface TemplateEditPageProps {
user: string;
template: LayoutTemplate;
error?: string;
success?: string;
}
export function TemplateEditPage(props: TemplateEditPageProps) {
const t = props.template;
return (
<Layout
title={`Template: ${t.name}`}
user={props.user}
activeNav="templates"
flash={
props.error ? { type: "error", message: props.error }
: props.success ? { type: "success", message: props.success }
: undefined
}
>
<div style="max-width:700px">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Template</h2>
<form method="post" action={`/admin/templates/${t.id}`}>
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-input" value={t.name} required maxlength="128" />
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" name="description" type="text" class="form-input" value={t.description ?? ""} />
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/templates" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
</form>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid: {String(t.grid_cols)}x{String(t.grid_rows)}</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Region</th>
<th>Position</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{t.regions.length === 0 ? (
<tr><td colspan="3" style="text-align:center; color:#999; padding:1rem">No regions defined</td></tr>
) : (
t.regions.map((r) => (
<tr>
<td><strong>{r.name}</strong></td>
<td>row {String(r.row)}, col {String(r.col)}</td>
<td>{String(r.rowSpan)}x{String(r.colSpan)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Visual grid preview */}
{t.regions.length > 0 && (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Preview</h2>
<div style={`display:grid; grid-template-columns:repeat(${String(t.grid_cols)}, 1fr); grid-template-rows:repeat(${String(t.grid_rows)}, 30px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
{t.regions.map((r) => (
<div style={`grid-column:${String(r.col + 1)} / span ${String(r.colSpan)}; grid-row:${String(r.row + 1)} / span ${String(r.rowSpan)}; background:#dbeafe; display:flex; align-items:center; justify-content:center; font-size:0.75rem; font-weight:600; color:#1e40af; border-radius:2px`}>
{r.name}
</div>
))}
</div>
</div>
)}
{!t.is_builtin && (
<form method="post" action={`/admin/templates/${t.id}/delete`} style="margin-top:1rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this template? Layouts using it will break.')"}}>Delete Template</button>
</form>
)}
</div>
</Layout>
);
}
// ---- Layouts ----------------------------------------------------------------
interface LayoutsPageProps {
user: string;
layouts: LayoutType[];
templates: Map<number, LayoutTemplate>;
displays: Map<number, Display>;
}
export function LayoutsPage(props: LayoutsPageProps) {
return (
<Layout title="Layouts" user={props.user} activeNav="layouts">
<div class="section-header">
<h2 class="section-title">All Layouts</h2>
<a href="/admin/layouts/new" class="btn btn-primary">New Layout</a>
</div>
<p style="color:#666; margin-bottom:1.25rem">
A layout binds cameras and other content into a template's regions for one display.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Template</th>
<th>Display</th>
<th>Priority</th>
<th>Default</th>
</tr>
</thead>
<tbody>
{props.layouts.length === 0 ? (
<tr><td colspan="5" style="text-align:center; color:#999; padding:2rem">No layouts created yet</td></tr>
) : (
props.layouts.map((l) => {
const tmpl = props.templates.get(l.template_id);
const disp = props.displays.get(l.display_id);
return (
<tr>
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
<td>{tmpl ? tmpl.name : `#${String(l.template_id)}`}</td>
<td>{disp ? disp.name : `#${String(l.display_id)}`}</td>
<td>
{l.priority === "hot"
? <span class="badge badge-red">hot</span>
: l.priority === "cold"
? <span class="badge badge-blue">cold</span>
: <span class="badge badge-gray">normal</span>
}
</td>
<td>
{l.is_default ? <span class="badge badge-green">Yes</span> : ""}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Layout>
);
}
// ---- Layout New -------------------------------------------------------------
interface LayoutNewPageProps {
user: string;
templates: LayoutTemplate[];
displays: Display[];
error?: string;
values?: Record<string, string>;
}
export function LayoutNewPage(props: LayoutNewPageProps) {
const v = props.values ?? {};
return (
<Layout
title="New Layout"
user={props.user}
activeNav="layouts"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div style="max-width:600px">
<form method="post" action="/admin/layouts/new">
<div class="form-group">
<label for="name">Layout Name</label>
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
</div>
<div class="form-group">
<label for="template_id">Template</label>
<select id="template_id" name="template_id" class="form-input" required>
<option value="">-- Select Template --</option>
{props.templates.map((t) => (
<option value={String(t.id)} selected={v["template_id"] === String(t.id)}>
{t.name} ({String(t.grid_cols)}x{String(t.grid_rows)}, {String(t.regions.length)} regions)
</option>
))}
</select>
{props.templates.length === 0 && (
<div class="form-hint">
No templates exist. <a href="/admin/templates/new">Create one first</a>.
</div>
)}
</div>
<div class="form-group">
<label for="display_id">Display</label>
<select id="display_id" name="display_id" class="form-input" required>
<option value="">-- Select Display --</option>
{props.displays.map((d) => (
<option value={String(d.id)} selected={v["display_id"] === String(d.id)}>
{d.name} ({String(d.width_px)}x{String(d.height_px)})
</option>
))}
</select>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="form-input">
<option value="normal" selected={v["priority"] !== "hot" && v["priority"] !== "cold"}>Normal</option>
<option value="hot" selected={v["priority"] === "hot"}>Hot (always warm)</option>
<option value="cold" selected={v["priority"] === "cold"}>Cold</option>
</select>
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1" checked={v["is_default"] === "1"} />
{" "}Set as default layout for this display
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="resets_idle_timer" value="1" checked={v["resets_idle_timer"] !== "0"} />
{" "}Resets idle timer when activated
</label>
</div>
<button type="submit" class="btn btn-primary">Create Layout</button>
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
</Layout>
);
}
// ---- Layout Edit ------------------------------------------------------------
interface LayoutEditPageProps {
user: string;
layout: LayoutType;
template: LayoutTemplate;
display: Display;
cells: LayoutCell[];
cameras: Camera[];
error?: string;
success?: string;
}
export function LayoutEditPage(props: LayoutEditPageProps) {
const l = props.layout;
const t = props.template;
// Build a map from region_name → cell for easy lookup
const cellByRegion = new Map<string, LayoutCell>();
for (const c of props.cells) {
cellByRegion.set(c.region_name, c);
}
// Also build camera name lookup
const cameraById = new Map<number, Camera>();
for (const cam of props.cameras) {
cameraById.set(cam.id, cam);
}
return (
<Layout
title={`Layout: ${l.name}`}
user={props.user}
activeNav="layouts"
flash={
props.error ? { type: "error", message: props.error }
: props.success ? { type: "success", message: props.success }
: undefined
}
>
<div style="max-width:800px">
{/* Settings */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Settings</h2>
<form method="post" action={`/admin/layouts/${l.id}`}>
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-input" value={l.name} required maxlength="128" />
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" name="description" type="text" class="form-input" value={l.description ?? ""} />
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="form-input">
<option value="normal" selected={l.priority === "normal"}>Normal</option>
<option value="hot" selected={l.priority === "hot"}>Hot</option>
<option value="cold" selected={l.priority === "cold"}>Cold</option>
</select>
</div>
<div class="form-group">
<label for="cooling_timeout_seconds">Cooling Timeout (seconds)</label>
<input id="cooling_timeout_seconds" name="cooling_timeout_seconds" type="number" class="form-input" value={l.cooling_timeout_seconds != null ? String(l.cooling_timeout_seconds) : ""} min="0" placeholder="None" />
<div class="form-hint">How long streams stay warm after leaving this layout. Leave blank for no timeout.</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1" checked={l.is_default} />
{" "}Default layout for display
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="resets_idle_timer" value="1" checked={l.resets_idle_timer} />
{" "}Resets idle timer
</label>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
</form>
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
<div>Template: <a href={`/admin/templates/${t.id}`}>{t.name}</a> ({String(t.grid_cols)}x{String(t.grid_rows)})</div>
<div>Display: <a href={`/admin/displays/${props.display.id}`}>{props.display.name}</a></div>
</div>
</div>
{/* Template preview with cell assignments */}
{t.regions.length > 0 && (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid Preview</h2>
<div style={`display:grid; grid-template-columns:repeat(${String(t.grid_cols)}, 1fr); grid-template-rows:repeat(${String(t.grid_rows)}, 40px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
{t.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 (
<div style={`grid-column:${String(r.col + 1)} / span ${String(r.colSpan)}; grid-row:${String(r.row + 1)} / span ${String(r.rowSpan)}; background:${bgColor}; display:flex; align-items:center; justify-content:center; font-size:0.7rem; font-weight:600; color:${textColor}; border-radius:2px; overflow:hidden; text-overflow:ellipsis; padding:0 4px`}>
{label}
</div>
);
})}
</div>
</div>
)}
{/* Cell assignments table */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Cell Assignments</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Region</th>
<th>Content</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{t.regions.map((r) => {
const cell = cellByRegion.get(r.name);
return (
<tr>
<td><strong>{r.name}</strong></td>
<td>
{cell ? (
<span>
<span class="badge badge-blue">{cell.content_type}</span>
{" "}
{cell.content_type === "camera" && cell.camera_id
? (cameraById.get(cell.camera_id)?.name ?? `#${String(cell.camera_id)}`)
: cell.content_type === "web" && cell.web_url
? <span style="font-size:0.8rem; color:#666">{cell.web_url}</span>
: cell.content_type === "html"
? <span style="font-size:0.8rem; color:#666">(custom HTML)</span>
: ""
}
</span>
) : (
<span style="color:#999">Empty</span>
)}
</td>
<td>
{cell && (
<form method="post" action={`/admin/layouts/${l.id}/cells/${cell.id}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Remove this cell?')"}}>Remove</button>
</form>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Add cell form */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Assign Content to Region</h2>
<form method="post" action={`/admin/layouts/${l.id}/cells`}>
<div class="form-group">
<label for="region_name">Region</label>
<select id="region_name" name="region_name" class="form-input" required>
<option value="">-- Select Region --</option>
{t.regions.map((r) => {
const taken = cellByRegion.has(r.name);
return (
<option value={r.name} disabled={taken}>
{r.name}{taken ? " (assigned)" : ""}
</option>
);
})}
</select>
</div>
<div class="form-group">
<label for="content_type">Content Type</label>
<select id="content_type" name="content_type" class="form-input" required>
<option value="camera">Camera</option>
<option value="web">Web URL</option>
<option value="html">HTML</option>
</select>
</div>
<div id="camera-fields">
<div class="form-group">
<label for="camera_id">Camera</label>
<select id="camera_id" name="camera_id" class="form-input">
<option value="">-- Select Camera --</option>
{props.cameras.map((cam) => (
<option value={String(cam.id)}>{cam.name}</option>
))}
</select>
</div>
<div class="form-group">
<label for="stream_selector">Stream</label>
<select id="stream_selector" name="stream_selector" class="form-input">
<option value="auto">Auto</option>
<option value="main">Main</option>
<option value="sub">Sub</option>
</select>
</div>
</div>
<div id="web-fields" style="display:none">
<div class="form-group">
<label for="web_url">URL</label>
<input id="web_url" name="web_url" type="url" class="form-input" placeholder="https://example.com" />
</div>
</div>
<div id="html-fields" style="display:none">
<div class="form-group">
<label for="html_content">HTML Content</label>
<textarea id="html_content" name="html_content" class="form-input" rows="4" placeholder="<div>...</div>"></textarea>
</div>
</div>
<button type="submit" class="btn btn-primary">Assign</button>
</form>
</div>
<script>{js(
`(function(){` +
`var sel=document.getElementById("content_type");` +
`var cf=document.getElementById("camera-fields");` +
`var wf=document.getElementById("web-fields");` +
`var hf=document.getElementById("html-fields");` +
`function t(){var v=sel?sel.value:"camera";` +
`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();})()`
)}</script>
<form method="post" action={`/admin/layouts/${l.id}/delete`} style="margin-top:1rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this layout and all its cells?')"}}>Delete Layout</button>
</form>
</div>
</Layout>
);
}
// ---- Display Edit -----------------------------------------------------------
interface DisplayEditPageProps {
user: string;
display: Display;
layouts: LayoutType[];
error?: string;
success?: string;
}
export function DisplayEditPage(props: DisplayEditPageProps) {
const d = props.display;
return (
<Layout
title={`Display: ${d.name}`}
user={props.user}
activeNav="displays"
flash={
props.error ? { type: "error", message: props.error }
: props.success ? { type: "success", message: props.success }
: undefined
}
>
<div style="max-width:600px">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Display Info</h2>
<div style="color:#666; font-size:0.85rem; margin-bottom:1rem">
<div>Index: {String(d.index)}</div>
<div>Resolution: {String(d.width_px)}x{String(d.height_px)}</div>
<div>Primary: {d.is_primary ? "Yes" : "No"}</div>
</div>
<form method="post" action={`/admin/displays/${d.id}`}>
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-input" value={d.name} required />
</div>
<div class="form-group">
<label for="default_layout_id">Default Layout</label>
<select id="default_layout_id" name="default_layout_id" class="form-input">
<option value="">-- None --</option>
{props.layouts.map((l) => (
<option value={String(l.id)} selected={d.default_layout_id === l.id}>
{l.name}
</option>
))}
</select>
<div class="form-hint">Layout shown on idle revert.</div>
</div>
<div class="form-group">
<label for="idle_timeout_seconds">Idle Timeout (seconds)</label>
<input id="idle_timeout_seconds" name="idle_timeout_seconds" type="number" class="form-input" value={String(d.idle_timeout_seconds)} min="0" />
<div class="form-hint">Revert to default layout after this many seconds of inactivity. 0 to disable.</div>
</div>
<div class="form-group">
<label for="sleep_timeout_seconds">Sleep Timeout (seconds)</label>
<input id="sleep_timeout_seconds" name="sleep_timeout_seconds" type="number" class="form-input" value={String(d.sleep_timeout_seconds)} min="0" />
<div class="form-hint">Send CEC standby after this many seconds of inactivity. 0 to disable.</div>
</div>
<div class="form-group">
<label for="width_px">Width (px)</label>
<input id="width_px" name="width_px" type="number" class="form-input" value={String(d.width_px)} min="1" />
</div>
<div class="form-group">
<label for="height_px">Height (px)</label>
<input id="height_px" name="height_px" type="number" class="form-input" value={String(d.height_px)} min="1" />
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/displays" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
</form>
</div>
{props.layouts.length > 0 && (
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layouts on This Display</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Priority</th>
<th>Default</th>
</tr>
</thead>
<tbody>
{props.layouts.map((l) => (
<tr>
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
<td><span class={`badge ${l.priority === "hot" ? "badge-red" : l.priority === "cold" ? "badge-blue" : "badge-gray"}`}>{l.priority}</span></td>
<td>{l.is_default ? <span class="badge badge-green">Yes</span> : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Layout>
);
}
// ---- Displays List (with clickable links) -----------------------------------
interface DisplaysPageProps {
user: string;
displays: Display[];
}
export function DisplaysPage(props: DisplaysPageProps) {
return (
<Layout title="Displays" user={props.user} activeNav="displays">
<p style="color:#666; margin-bottom:1.25rem">Physical HDMI displays. Primary display created during setup.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{props.displays.length === 0 ? (
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
) : (
props.displays.map((d) => (
<tr>
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td>
<td style="color:#666">{String(d.width_px)}x{String(d.height_px)} index {String(d.index)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
// ---- Helpers ---------------------------------------------------------------- // ---- Helpers ----------------------------------------------------------------
function formatTime(iso: string): string { function formatTime(iso: string): string {