From cc306cec57a861220549765e7800f310e036fe0b Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 10 May 2026 03:45:53 +0200 Subject: [PATCH] feat: layout/template/display CRUD + display-chain bundle routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../service-admin-http/routes-admin.ts | 318 ++++++- .../service-admin-http/routes-setup.ts | 25 +- .../src/plugins/service-store/repository.ts | 238 +++++- server/src/shared/bundle.ts | 159 ++-- server/src/shared/pairing.ts | 5 + server/src/web-templates/admin-pages.tsx | 807 +++++++++++++++++- 6 files changed, 1455 insertions(+), 97 deletions(-) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 9ee4382..4bb862b 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -13,8 +13,16 @@ import { KiosksPage, KioskEditPage, LabelsPage, - SimpleListPage, + TemplatesPage, + TemplateNewPage, + TemplateEditPage, + LayoutsPage, + LayoutNewPage, + LayoutEditPage, + DisplaysPage, + DisplayEditPage, } from "../../web-templates/admin-pages.js"; +import type { LayoutTemplate, Display } from "../../shared/types.js"; export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Overview ------------------------------------------------------------- @@ -167,43 +175,305 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { 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) => { const user = event.context.user!; - return htmlPage(SimpleListPage({ - user: user.username, - pageTitle: "Layout Templates", - description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.", - activeNav: "templates", - items: [], // TODO: list templates - })); + const templates = deps.repo.listLayoutTemplates(); + return htmlPage(TemplatesPage({ user: user.username, 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>(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>(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) => { 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(); + for (const tid of templateIds) { + const t = deps.repo.getLayoutTemplateById(tid); + if (t) templates.set(tid, t); + } + const displays = new Map(); + 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, - pageTitle: "Layouts", - description: "A layout binds cameras and other content into a template's regions for one display.", - activeNav: "layouts", - items: [], // TODO: list layouts + templates: deps.repo.listLayoutTemplates(), + displays: deps.repo.listDisplays(), })); }); + app.post("/admin/layouts/new", async (event) => { + const user = event.context.user!; + const body = await readBody>(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>(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>(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) => { const user = event.context.user!; const displays = deps.repo.listDisplays(); - return htmlPage(SimpleListPage({ - user: user.username, - pageTitle: "Displays", - description: "Physical HDMI displays. Primary display created during setup.", - activeNav: "displays", - items: displays.map((d) => ({ - name: d.name, - detail: `${d.width_px}x${d.height_px} — index ${d.index}`, - })), - })); + return htmlPage(DisplaysPage({ user: user.username, displays })); + }); + + app.get("/admin/displays/:id", (event) => { + const user = event.context.user!; + const id = Number(getRouterParam(event, "id")); + const display = deps.repo.getDisplayById(id); + 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>(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) => { diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts index c3ac4c9..fd70725 100644 --- a/server/src/plugins/service-admin-http/routes-setup.ts +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -45,7 +45,30 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void { deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); 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: '

BetterFrame

', + }); + deps.repo.updateDisplay(display.id, { default_layout_id: layout.id }); deps.repo.markSetupComplete(); return new Response(null, { diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index a1d2f9b..7abde6f 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -359,6 +359,238 @@ export class Repository { return d; } + updateDisplay(id: number, patch: Partial): 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)); + } + + getLayoutTemplateById(id: number): LayoutTemplate | null { + const r = this.prep("SELECT * FROM layout_templates WHERE id = ?").get(id); + return r ? rowToLayoutTemplate(r as Record) : 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)); + } + + getLayoutById(id: number): Layout | null { + const r = this.prep("SELECT * FROM layouts WHERE id = ?").get(id); + return r ? rowToLayout(r as Record) : 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)); + } + + 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): 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; + }): 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); + } + + updateLayoutCell(id: number, patch: Partial): 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)); + } + + 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)); + } + // =========================================================================== // cameras // =========================================================================== @@ -564,17 +796,19 @@ export class Repository { key_prefix: string; capabilities?: string[]; hardware_model?: string | null; + display_id?: number | null; }): Kiosk { const result = this.prep( `INSERT INTO kiosks - (name, key_hash, key_prefix, capabilities, hardware_model, paired_at) - VALUES (?, ?, ?, ?, ?, ?)`, + (name, key_hash, key_prefix, capabilities, hardware_model, display_id, paired_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( input.name, input.key_hash, input.key_prefix, J(input.capabilities ?? []), input.hardware_model ?? null, + input.display_id ?? null, isoNow(), ); const id = Number(result.lastInsertRowid); diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 88fca6f..516427b 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -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, - * encrypts ONVIF passwords with cluster key, returns versioned bundle. + * kiosk.display_id → layouts for display → cells → cameras + * No label filtering for v0.1. */ import { createHash } from "node:crypto"; import type { Repository } from "../plugins/service-store/repository.js"; @@ -17,6 +17,7 @@ export interface BundleCamera { onvif_port: number | null; onvif_username: string | null; onvif_password_encrypted: string | null; + stream_policy: string; streams: Array<{ id: number; 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 { id: number; name: string; - template_id: number | null; - 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<{ + template: { id: number; name: string; regions: unknown; grid_cols: 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; } @@ -73,11 +84,46 @@ export function generateBundle( clusterKey: string | undefined, ): KioskBundle | null { const kiosk = repo.getKioskById(kioskId); - if (!kiosk) return null; + if (!kiosk || !kiosk.display_id) return null; - const scope = repo.bundleScope(kioskId); - const cameras = repo.camerasForLabelIds(scope.labelIds); - const layouts = repo.layoutsForLabelIds(scope.labelIds); + const display = repo.getDisplayById(kiosk.display_id); + if (!display) return null; + + 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 streams = repo.listCameraStreams(cam.id); @@ -94,6 +140,7 @@ export function generateBundle( onvif_port: cam.onvif_port, onvif_username: cam.onvif_username, onvif_password_encrypted: onvifPwEncrypted, + stream_policy: cam.stream_policy, streams: streams.map((s) => ({ id: s.id, 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 = { kiosk_id: kioskId, kiosk_name: kiosk.name, - labels: scope.labelNames, - operate_labels: scope.operateLabelNames, - cameras: bundleCameras, + display: { + id: display.id, + 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, - templates: templates.map((t) => ({ - id: t.id, - name: t.name, - regions: t.regions, - grid_cols: t.grid_cols, - grid_rows: t.grid_rows, - })), + cameras: bundleCameras, version: "", }; diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index f47053e..529b03e 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -123,12 +123,17 @@ export async function confirmPairing( const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); 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({ name: kioskName, key_hash: kioskKeyHash, key_prefix: kioskKeyPrefix, capabilities: pc.kiosk_capabilities, hardware_model: pc.kiosk_hardware_model, + display_id: primaryDisplay?.id ?? null, }); // Attach initial labels diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index b3e4808..dbd1767 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -3,7 +3,18 @@ */ import { js } from "jsx-htmx"; 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 --------------------------------------------------------------- @@ -800,6 +811,800 @@ export function LabelsPage(props: LabelsPageProps) { ); } +// ---- Templates (Layout Templates) ------------------------------------------- + +interface TemplatesPageProps { + user: string; + templates: LayoutTemplate[]; +} + +export function TemplatesPage(props: TemplatesPageProps) { + return ( + +
+

All Templates

+ New Template +
+

+ Templates define named regions on a grid. Layouts bind content into these regions. +

+
+ + + + + + + + + + + {props.templates.length === 0 ? ( + + ) : ( + props.templates.map((t) => ( + + + + + + + )) + )} + +
NameGridRegionsType
No templates created yet
{t.name}{String(t.grid_cols)}x{String(t.grid_rows)}{String(t.regions.length)} region{t.regions.length !== 1 ? "s" : ""} + {t.is_builtin + ? Built-in + : Custom + } +
+
+
+ ); +} + +// ---- Template New ----------------------------------------------------------- + +interface TemplateNewPageProps { + user: string; + error?: string; + values?: Record; +} + +export function TemplateNewPage(props: TemplateNewPageProps) { + const v = props.values ?? {}; + return ( + +
+
+

Choose a Preset

+

+ Pick a preset to get started quickly, or choose Custom to define your own regions. +

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+

Custom Template

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed. +
+
+ + Cancel +
+
+
+
+ ); +} + +// ---- Template Edit ---------------------------------------------------------- + +interface TemplateEditPageProps { + user: string; + template: LayoutTemplate; + error?: string; + success?: string; +} + +export function TemplateEditPage(props: TemplateEditPageProps) { + const t = props.template; + return ( + +
+
+

Edit Template

+
+
+ + +
+
+ + +
+ + Back +
+
+ +
+

Grid: {String(t.grid_cols)}x{String(t.grid_rows)}

+
+ + + + + + + + + + {t.regions.length === 0 ? ( + + ) : ( + t.regions.map((r) => ( + + + + + + )) + )} + +
RegionPositionSize
No regions defined
{r.name}row {String(r.row)}, col {String(r.col)}{String(r.rowSpan)}x{String(r.colSpan)}
+
+
+ + {/* Visual grid preview */} + {t.regions.length > 0 && ( +
+

Preview

+
+ {t.regions.map((r) => ( +
+ {r.name} +
+ ))} +
+
+ )} + + {!t.is_builtin && ( +
+ +
+ )} +
+
+ ); +} + +// ---- Layouts ---------------------------------------------------------------- + +interface LayoutsPageProps { + user: string; + layouts: LayoutType[]; + templates: Map; + displays: Map; +} + +export function LayoutsPage(props: LayoutsPageProps) { + return ( + +
+

All Layouts

+ New Layout +
+

+ A layout binds cameras and other content into a template's regions for one display. +

+
+ + + + + + + + + + + + {props.layouts.length === 0 ? ( + + ) : ( + props.layouts.map((l) => { + const tmpl = props.templates.get(l.template_id); + const disp = props.displays.get(l.display_id); + return ( + + + + + + + + ); + }) + )} + +
NameTemplateDisplayPriorityDefault
No layouts created yet
{l.name}{tmpl ? tmpl.name : `#${String(l.template_id)}`}{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; + templates: LayoutTemplate[]; + displays: Display[]; + error?: string; + values?: Record; +} + +export function LayoutNewPage(props: LayoutNewPageProps) { + const v = props.values ?? {}; + return ( + +
+
+
+ + +
+ +
+ + + {props.templates.length === 0 && ( +
+ No templates exist. Create one first. +
+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + Cancel +
+
+
+ ); +} + +// ---- 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(); + 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 +
+
+
Template: {t.name} ({String(t.grid_cols)}x{String(t.grid_rows)})
+ +
+
+ + {/* Template preview with cell assignments */} + {t.regions.length > 0 && ( +
+

Grid Preview

+
+ {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 ( +
+ {label} +
+ ); + })} +
+
+ )} + + {/* Cell assignments table */} +
+

Cell Assignments

+
+ + + + + + + + + + {t.regions.map((r) => { + const cell = cellByRegion.get(r.name); + return ( + + + + + + ); + })} + +
RegionContentActions
{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[]; + 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)}
+
Primary: {d.is_primary ? "Yes" : "No"}
+
+
+
+ + +
+ +
+ + +
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) => ( + + + + + + ))} + +
NamePriorityDefault
{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. Primary display created during setup.

+
+ + + + + + + + + {props.displays.length === 0 ? ( + + ) : ( + props.displays.map((d) => ( + + + + + )) + )} + +
NameDetails
None configured yet
{d.name}{String(d.width_px)}x{String(d.height_px)} — index {String(d.index)}
+
+
+ ); +} + // ---- Helpers ---------------------------------------------------------------- function formatTime(iso: string): string {