refactor: merge templates into layouts, displays from kiosks

- Eliminated layout_templates as separate entity — regions/grid now
  live directly on layouts
- Displays created from kiosk pairing (not standalone), each display
  has kiosk_id FK
- Removed Templates from sidebar nav and all template routes/pages
- Layout creation uses preset buttons (fullscreen, 2x2, 1+3, 3x3)
  that set regions directly on the layout
- Setup no longer creates default display/layout (deferred to pairing)
- Pairing creates HDMI-0 display for new kiosk
- Bundle reads regions from layout directly, no template lookup
- Rust kiosk updated to match new bundle format
- DB migration adds regions/grid_cols/grid_rows to layouts, kiosk_id
  to displays, copies existing template data
This commit is contained in:
Mitchell R 2026-05-10 21:39:09 +02:00
parent 72d8ad717f
commit 7fbda3c2b3
15 changed files with 500 additions and 531 deletions

143
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,143 @@
# BetterFrame — Architecture
## Goals
- Display up to 32 cameras simultaneously on a Pi 5 driving HDMI.
- Mixed cells: cameras, web pages (iframe), and custom HTML.
- Layouts switch with no perceptible latency, driven by API or camera events.
- Layout templates (named regions) compile to a pixel grid at runtime.
- Cameras configured via raw RTSP or ONVIF (auto-discover streams + capabilities).
- API-key-protected REST API for everything except local kiosk reads.
- Single display in v1; data model already supports multi-display.
## Process layout
Two processes on the Pi, coordinating over a local WebSocket:
```
┌──────────────────────────────────────────────────────────────────┐
│ Raspberry Pi 5 │
│ │
│ ┌────────────────────────────┐ ┌───────────────────────────┐ │
│ │ Kiosk (Rust + GTK4) │ │ Backend (FastAPI) │ │
│ │ │ │ │ │
│ │ Decoder pool (warm/hot) │◄───┤ - SQLite │ │
│ │ Grid renderer (GTK4) │ │ - ONVIF service │ │
│ │ WebKitGTK pool │ WS │ - Layout API │ │
│ │ │ │ - Event rules engine │ │
│ └──────────────┬─────────────┘ │ - API key auth │ │
│ │ │ - Static admin UI │ │
│ │ RTSP └────────────┬──────────────┘ │
└─────────────────┼───────────────────────────────┼────────────────┘
▼ │
┌─────────────────┐ ▼
│ IP cameras │ LAN clients (port 8080)
│ RTSP / ONVIF │
└─────────────────┘
```
## Why these choices
**Rust kiosk + Python backend.** Rust where the latency budget is tight
(pipeline state changes, decoder management, render loop). Python where the
ecosystem matters (`onvif-zeep`, FastAPI, alembic). They communicate via
WebSocket so neither is locked to the other's runtime.
**SQLite, not Postgres.** Total dataset is hundreds of rows. WAL mode handles
the kiosk-as-reader case fine, atomic schema migrations are easy, single-file
backup is trivial.
**GStreamer for video.** Only realistic choice on Linux for hardware-accelerated
multi-camera. Pi 5 V4L2 M2M decoder is exposed via `v4l2h264dec`; `gstreamer-rs`
bindings are mature.
## Stream warmth model
Each `(camera_id, stream_type)` pair is in one of four states:
| State | RTSP open | Decoder running | Visible | Promote cost |
|----------|-----------|-----------------|---------|--------------|
| Hot | yes | yes | yes | 0 |
| Warm | yes | yes (paused) | no | ~1 frame |
| Cooling | yes | yes | no | 0 |
| Cold | no | no | no | 1-3 seconds |
The kiosk computes the needed warm set on every layout activation:
```
warm_set =
streams_used_by_active_layout
streams_in_layout_preload_list
streams_used_by_priority_hot_layouts (always-on)
streams_currently_in_cooling_window
```
Anything outside that set transitions to cooling, then cold when its timeout
expires.
## Layout templates
Templates define named regions in a normalized 12×12 grid. Layouts reference a
template and bind cameras or content to its named regions.
```yaml
templates:
- id: 1-big-7-small
regions:
- { name: main, x: 0, y: 0, w: 8, h: 8 }
- { name: tr-1, x: 8, y: 0, w: 4, h: 2 }
- { name: tr-2, x: 8, y: 2, w: 4, h: 2 }
# ...
layouts:
- id: front-overview
template_id: 1-big-7-small
bindings:
main: { type: camera, camera_id: 1, stream: main }
tr-1: { type: camera, camera_id: 2, stream: sub }
br-3: { type: web, url: "http://homeassistant.local/dashboard" }
priority: hot
cooling_timeout_seconds: 300
preload_camera_ids: [4, 5]
```
Templates compile to pixel rectangles at the kiosk based on actual display
resolution. Cells under 20% of total display area default to sub-stream;
≥20% default to main; per-cell override always wins.
## Event rules engine
ONVIF cameras with event support get a persistent PullPoint subscription managed
by the backend. Events are normalized to `{camera_id, topic, payload}` and
matched against rules:
```yaml
event_rules:
- when:
camera_id: 5
topic: "tns1:RuleEngine/CellMotionDetector/Motion"
property_op: "Changed"
do:
action: activate_layout
layout_id: front-door-zoom
revert_after_seconds: 60
revert_to: previous
cooldown_seconds: 30
```
External systems fire synthetic events via `POST /api/events/trigger`, so
non-ONVIF inputs work through the same engine.
## Auth
- **Kiosk → backend**: WebSocket on `127.0.0.1:8000`, no auth (loopback only).
- **LAN → backend**: HTTP on `0.0.0.0:8080`, every route requires `X-API-Key`.
Two listeners, two middleware stacks, same FastAPI app.
## Multi-display readiness
Schema includes `display_id` on `layouts` and a `displays` table. v1 hard-codes
a single display row. The kiosk↔backend protocol includes `display_id` in
every activation message. Adding a second display later: new `displays` row,
new kiosk instance bound to it, no API changes.

View file

@ -25,7 +25,9 @@ pub struct BundleDisplay {
pub struct BundleLayout {
pub id: u32,
pub name: String,
pub template: Option<BundleTemplate>,
pub regions: Vec<BundleRegion>,
pub grid_cols: u32,
pub grid_rows: u32,
pub priority: String,
pub cooling_timeout_seconds: Option<u32>,
pub preload_camera_ids: Vec<u32>,
@ -34,15 +36,6 @@ pub struct BundleLayout {
pub cells: Vec<BundleCell>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleTemplate {
pub id: u32,
pub name: String,
pub regions: Vec<BundleRegion>,
pub grid_cols: u32,
pub grid_rows: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleRegion {
pub name: String,

View file

@ -120,14 +120,14 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
return;
};
let Some(ref template) = layout.template else {
warn!("layout has no template");
if layout.regions.is_empty() {
warn!("layout has no regions");
show_logo(window);
return;
};
}
info!("rendering layout '{}' with {}x{} grid, {} cells",
layout.name, template.grid_cols, template.grid_rows, layout.cells.len());
layout.name, layout.grid_cols, layout.grid_rows, layout.cells.len());
let grid = Grid::new();
grid.set_row_homogeneous(true);
@ -141,9 +141,9 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
let pipelines: Rc<RefCell<Vec<gstreamer::Pipeline>>> = Rc::new(RefCell::new(Vec::new()));
for cell in &layout.cells {
let region = template.regions.iter().find(|r| r.name == cell.region_name);
let region = layout.regions.iter().find(|r| r.name == cell.region_name);
let Some(region) = region else {
warn!("region '{}' not found in template", cell.region_name);
warn!("region '{}' not found in layout", cell.region_name);
continue;
};
@ -200,7 +200,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
}
// Fill empty regions
for region in &template.regions {
for region in &layout.regions {
if !layout.cells.iter().any(|c| c.region_name == region.name) {
let empty = GtkBox::new(Orientation::Vertical, 0);
add_css(&empty, "box { background-color: #111; }");

View file

@ -13,16 +13,13 @@ import {
KiosksPage,
KioskEditPage,
LabelsPage,
TemplatesPage,
TemplateNewPage,
TemplateEditPage,
LayoutsPage,
LayoutNewPage,
LayoutEditPage,
DisplaysPage,
DisplayEditPage,
} from "../../web-templates/admin-pages.js";
import type { LayoutTemplate, Display } from "../../shared/types.js";
import type { Display } from "../../shared/types.js";
function sanitizeRtspUrl(raw: string): string {
const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/);
@ -196,33 +193,47 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
});
// ---- Templates (Layout Templates) ------------------------------------------
// ---- Layouts ---------------------------------------------------------------
app.get("/admin/templates", (event) => {
app.get("/admin/layouts", (event) => {
const user = event.context.user!;
const templates = deps.repo.listLayoutTemplates();
return htmlPage(TemplatesPage({ user: user.username, templates }));
const layouts = deps.repo.listLayouts();
const displayIds = [...new Set(layouts.map((l) => l.display_id))];
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, displays }));
});
app.get("/admin/templates/new", (event) => {
app.get("/admin/layouts/new", (event) => {
const user = event.context.user!;
return htmlPage(TemplateNewPage({ user: user.username }));
return htmlPage(LayoutNewPage({
user: user.username,
displays: deps.repo.listDisplays(),
}));
});
app.post("/admin/templates/new", async (event) => {
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 preset = body?.["preset"] ?? "custom";
let name = (body?.["name"] ?? "").trim();
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 (!name || name.length > 128) errors.push("Name required (max 128 chars).");
if (isNaN(displayId)) errors.push("Select a display.");
let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }> = [];
let gridCols = 12;
let gridRows = 12;
type Region = { name: string; row: number; col: number; rowSpan: number; colSpan: number };
let regions: Region[] = [];
let gridCols = 1;
let gridRows = 1;
if (preset === "fullscreen") {
gridCols = 1;
@ -262,14 +273,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
];
} else {
// Custom
gridCols = parseInt(body?.["grid_cols"] ?? "12", 10);
gridRows = parseInt(body?.["grid_rows"] ?? "12", 10);
gridCols = parseInt(body?.["grid_cols"] ?? "1", 10);
gridRows = parseInt(body?.["grid_rows"] ?? "1", 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.");
errors.push("Regions JSON is required for custom layout.");
} else {
try {
regions = JSON.parse(regionsStr);
@ -282,97 +293,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}
}
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) => {
const user = event.context.user!;
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,
templates: deps.repo.listLayoutTemplates(),
displays: deps.repo.listDisplays(),
}));
});
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,
@ -382,7 +305,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const layout = deps.repo.createLayout({
name,
description,
template_id: templateId,
regions,
grid_cols: gridCols,
grid_rows: gridRows,
display_id: displayId,
priority,
is_default: isDefault,
@ -397,8 +322,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
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);
@ -406,7 +329,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return htmlPage(LayoutEditPage({
user: user.username,
layout,
template,
display,
cells,
cameras,
@ -479,7 +401,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
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 }));
const kiosk = display.kiosk_id ? deps.repo.getKioskById(display.kiosk_id) : null;
return htmlPage(DisplayEditPage({ user: user.username, display, layouts, kioskName: kiosk?.name ?? null }));
});
app.post("/admin/displays/:id", async (event) => {
@ -635,11 +558,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
name: kl.name,
role: kl.role,
}));
const displays = deps.repo.listDisplaysForKiosk(id);
return htmlPage(KioskEditPage({
user: user.username,
kiosk,
labels: kioskLabels,
allLabels: deps.repo.listLabels(),
displays,
}));
});

View file

@ -45,30 +45,9 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
deps.repo.markClusterKeyProvisioned();
// 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 });
// Setup only creates admin user + cluster key.
// Displays are created when kiosks are paired (kiosk reports HDMI ports).
// Layouts are created by admin after pairing.
deps.repo.markSetupComplete();
return new Response(null, {

View file

@ -121,8 +121,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
this.db.exec("PRAGMA busy_timeout = 10000");
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
for (const stmt of MIGRATIONS) {
this.db.exec(stmt);
for (const entry of MIGRATIONS) {
if (typeof entry === "string") {
this.db.exec(entry);
} else {
entry(this.db);
}
}
this._repo = new Repository(this.db, async (table, op, id) => {

View file

@ -109,6 +109,7 @@ export function rowToDisplay(r: Row): Display {
name: s(r["name"]),
index: n(r["index"]),
is_primary: b(r["is_primary"]),
kiosk_id: nn(r["kiosk_id"]),
width_px: n(r["width_px"]),
height_px: n(r["height_px"]),
default_layout_id: nn(r["default_layout_id"]),
@ -175,7 +176,10 @@ export function rowToLayout(r: Row): Layout {
id: n(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
template_id: n(r["template_id"]),
template_id: nn(r["template_id"]),
regions: j<LayoutRegion[]>(r["regions"], []),
grid_cols: n(r["grid_cols"]) || 1,
grid_rows: n(r["grid_rows"]) || 1,
display_id: n(r["display_id"]),
priority: s(r["priority"]) as LayoutPriority,
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),

View file

@ -13,7 +13,25 @@
* SQLAlchemy's DateTime adapter we avoid the whole class of issue here.)
*/
export const MIGRATIONS: readonly string[] = [
/**
* A migration entry: either a plain SQL string or a function receiving the DB.
* Functions are used for ALTER TABLE which lacks IF NOT EXISTS in SQLite.
*/
import type { DatabaseSync } from "node:sqlite";
export type MigrationEntry = string | ((db: DatabaseSync) => void);
function addColumnIfNotExists(
db: DatabaseSync,
table: string,
column: string,
definition: string,
): void {
const cols = db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>;
if (cols.some((c) => c.name === column)) return;
db.exec(`ALTER TABLE "${table}" ADD COLUMN ${column} ${definition}`);
}
export const MIGRATIONS: readonly MigrationEntry[] = [
// ---- users ---------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -249,4 +267,21 @@ export const MIGRATIONS: readonly string[] = [
`CREATE INDEX IF NOT EXISTS idx_event_log_received ON event_log(received_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`,
// ---- v0.2: flatten layout_templates into layouts, display→kiosk inversion ---
(db: DatabaseSync) => {
addColumnIfNotExists(db, "layouts", "regions", "TEXT NOT NULL DEFAULT '[]'");
addColumnIfNotExists(db, "layouts", "grid_cols", "INTEGER NOT NULL DEFAULT 1");
addColumnIfNotExists(db, "layouts", "grid_rows", "INTEGER NOT NULL DEFAULT 1");
// Copy template data into layouts (idempotent — only updates rows where regions is still '[]')
db.exec(`UPDATE layouts SET
regions = COALESCE((SELECT lt.regions FROM layout_templates lt WHERE lt.id = layouts.template_id), '[]'),
grid_cols = COALESCE((SELECT lt.grid_cols FROM layout_templates lt WHERE lt.id = layouts.template_id), 1),
grid_rows = COALESCE((SELECT lt.grid_rows FROM layout_templates lt WHERE lt.id = layouts.template_id), 1)
WHERE regions = '[]' AND template_id IS NOT NULL`);
addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL");
},
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
];

View file

@ -350,7 +350,7 @@ export class Repository {
createDefaultDisplay(): Display {
const result = this.prep(
`INSERT INTO displays (name, "index", is_primary)
VALUES ('primary', 0, 1)`,
VALUES ('primary', 0, 0)`,
).run();
const id = Number(result.lastInsertRowid);
void this.notify("displays", "create", id);
@ -359,6 +359,43 @@ export class Repository {
return d;
}
createDisplayForKiosk(kioskId: number, input: {
name: string;
index?: number;
width_px?: number;
height_px?: number;
}): Display {
// Find next available index
const idx = input.index ?? this.nextDisplayIndex();
const result = this.prep(
`INSERT INTO displays (name, "index", is_primary, kiosk_id, width_px, height_px)
VALUES (?, ?, 0, ?, ?, ?)`,
).run(
input.name,
idx,
kioskId,
input.width_px ?? 1920,
input.height_px ?? 1080,
);
const id = Number(result.lastInsertRowid);
void this.notify("displays", "create", id);
const d = this.getDisplayById(id);
if (!d) throw new Error("display vanished after insert");
return d;
}
listDisplaysForKiosk(kioskId: number): Display[] {
const rs = this.prep(
'SELECT * FROM displays WHERE kiosk_id = ? ORDER BY "index"',
).all(kioskId);
return rs.map((r) => rowToDisplay(r as Record<string, unknown>));
}
private nextDisplayIndex(): number {
const r = this.prep('SELECT MAX("index") AS m FROM displays').get() as { m: number | null } | undefined;
return (r?.m ?? -1) + 1;
}
updateDisplay(id: number, patch: Partial<Display>): void {
const sets: string[] = [];
const vals: unknown[] = [];
@ -457,7 +494,10 @@ export class Repository {
createLayout(input: {
name: string;
description?: string | null;
template_id: number;
template_id?: number | null;
regions: unknown;
grid_cols: number;
grid_rows: number;
display_id: number;
priority?: string;
cooling_timeout_seconds?: number | null;
@ -466,12 +506,15 @@ export class Repository {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO layouts (name, description, template_id, regions, grid_cols, grid_rows, 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.template_id ?? null,
J(input.regions),
input.grid_cols,
input.grid_rows,
input.display_id,
input.priority ?? "normal",
input.cooling_timeout_seconds ?? null,
@ -492,7 +535,7 @@ export class Repository {
for (const [k, v] of Object.entries(patch)) {
if (k === "id") continue;
sets.push(`${k} = ?`);
if (k === "preload_camera_ids") vals.push(J(v));
if (k === "preload_camera_ids" || k === "regions") vals.push(J(v));
else if (typeof v === "boolean") vals.push(B(v));
else vals.push(v === undefined ? null : v);
}
@ -810,19 +853,17 @@ 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, display_id, paired_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
(name, key_hash, key_prefix, capabilities, hardware_model, 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);

View file

@ -58,24 +58,13 @@ const camera = av.object(
{ unknownKeys: "reject" },
);
const layoutTemplate = av.object(
const layoutRegion = av.object(
{
id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
regions: av.array(
av.object(
{
name: av.string().minLength(1).maxLength(64),
row: av.int().min(0).max(11),
col: av.int().min(0).max(11),
rowSpan: av.int().min(1).max(12),
colSpan: av.int().min(1).max(12),
},
{ unknownKeys: "reject" },
),
),
grid_cols: av.int().min(1).max(64),
grid_rows: av.int().min(1).max(64),
name: av.string().minLength(1).maxLength(64),
row: av.int().min(0).max(11),
col: av.int().min(0).max(11),
rowSpan: av.int().min(1).max(12),
colSpan: av.int().min(1).max(12),
},
{ unknownKeys: "reject" },
);
@ -98,8 +87,9 @@ const layout = av.object(
{
id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
template_id: av.int().min(1),
display_id: av.int().min(1),
regions: av.array(layoutRegion),
grid_cols: av.int().min(1).max(64),
grid_rows: av.int().min(1).max(64),
priority: layoutPriority,
cooling_timeout_seconds: av.nullable(av.int().min(0)),
preload_camera_ids: av.array(av.int().min(1)),
@ -117,7 +107,6 @@ export const kioskBundle = av.object(
labels: av.array(av.string()),
operate_labels: av.array(av.string()),
cameras: av.array(camera),
templates: av.array(layoutTemplate),
layouts: av.array(layout),
version: av.string().minLength(1).maxLength(64),
},

View file

@ -43,13 +43,9 @@ export interface BundleCell {
export interface BundleLayout {
id: number;
name: string;
template: {
id: number;
name: string;
regions: unknown;
grid_cols: number;
grid_rows: number;
} | null;
regions: unknown;
grid_cols: number;
grid_rows: number;
priority: string;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
@ -84,9 +80,15 @@ export function generateBundle(
clusterKey: string | undefined,
): KioskBundle | null {
const kiosk = repo.getKioskById(kioskId);
if (!kiosk || !kiosk.display_id) return null;
if (!kiosk) return null;
const display = repo.getDisplayById(kiosk.display_id);
// Find display for this kiosk (displays now point to kiosks via kiosk_id)
const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
let display = kioskDisplays[0] ?? null;
if (!display && kiosk.display_id) {
display = repo.getDisplayById(kiosk.display_id);
}
if (!display) return null;
const layouts = repo.layoutsForDisplayId(display.id);
@ -97,17 +99,12 @@ export function generateBundle(
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,
regions: l.regions,
grid_cols: l.grid_cols,
grid_rows: l.grid_rows,
priority: l.priority,
cooling_timeout_seconds: l.cooling_timeout_seconds,
preload_camera_ids: l.preload_camera_ids,

View file

@ -123,17 +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,
});
// Create a default display for this kiosk (HDMI-0)
repo.createDisplayForKiosk(kiosk.id, {
name: `${kioskName} HDMI-0`,
});
// Attach initial labels

View file

@ -72,7 +72,8 @@ export interface Display {
id: number;
name: string;
index: number; // unique
is_primary: boolean;
is_primary: boolean; // deprecated — kept for backward compat, not used
kiosk_id: number | null; // FK → kiosks; displays belong to kiosks
width_px: number;
height_px: number;
default_layout_id: number | null;
@ -139,7 +140,10 @@ export interface Layout {
id: number;
name: string;
description: string | null;
template_id: number;
template_id: number | null; // deprecated — kept nullable for backward compat
regions: LayoutRegion[];
grid_cols: number;
grid_rows: number;
display_id: number;
priority: LayoutPriority;
cooling_timeout_seconds: number | null;
@ -175,7 +179,7 @@ export interface Kiosk {
paired_at: string | null;
last_seen_at: string | null;
last_bundle_version: string | null;
display_id: number | null;
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id
created_at: string;
}

View file

@ -11,7 +11,6 @@ import type {
Layout as LayoutType,
LayoutCell,
LayoutRegion,
LayoutTemplate,
PairingCode,
EventLog,
} from "../shared/types.js";
@ -712,6 +711,7 @@ interface KioskEditProps {
kiosk: Kiosk;
labels: Array<{ label_id: number; name: string; role: string }>;
allLabels: Label[];
displays?: Display[];
error?: string;
success?: string;
}
@ -753,6 +753,29 @@ export function KioskEditPage(props: KioskEditProps) {
</div>
</div>
{/* Associated displays */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Displays</h2>
{props.displays && props.displays.length > 0 ? (
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Resolution</th><th>Index</th></tr></thead>
<tbody>
{props.displays.map((d) => (
<tr>
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td>
<td>{String(d.width_px)}x{String(d.height_px)}</td>
<td>{String(d.index)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p style="color:#999">No displays associated with this kiosk</p>
)}
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
{props.labels.length > 0 ? (
@ -851,252 +874,11 @@ 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>;
}
@ -1108,14 +890,14 @@ export function LayoutsPage(props: LayoutsPageProps) {
<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.
A layout defines a grid of regions and binds cameras or other content into them for a display.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Template</th>
<th>Grid</th>
<th>Display</th>
<th>Priority</th>
<th>Default</th>
@ -1126,12 +908,11 @@ export function LayoutsPage(props: LayoutsPageProps) {
<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>{String(l.grid_cols)}x{String(l.grid_rows)} ({String(l.regions.length)} regions)</td>
<td>{disp ? disp.name : `#${String(l.display_id)}`}</td>
<td>
{l.priority === "hot"
@ -1159,7 +940,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
interface LayoutNewPageProps {
user: string;
templates: LayoutTemplate[];
displays: Display[];
error?: string;
values?: Record<string, string>;
@ -1174,73 +954,119 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
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 style="max-width:700px">
{/* Quick presets */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Quick Create from Preset</h2>
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
Pick a preset grid layout. You can also define a custom grid below.
</p>
<div class="stats-grid" style="margin-bottom:0">
{[
{ preset: "fullscreen", label: "Fullscreen", desc: "1x1 grid, single region" },
{ preset: "2x2", label: "2x2 Grid", desc: "4 equal regions" },
{ preset: "1plus3", label: "1+3", desc: "Large left, 3 stacked right" },
{ preset: "3x3", label: "3x3 Grid", desc: "9 equal regions" },
].map((p) => (
<form method="post" action="/admin/layouts/new" style="margin:0">
<input type="hidden" name="preset" value={p.preset} />
<input type="hidden" name="name" value={v["name"] || p.label} />
<input type="hidden" name="display_id" value={v["display_id"] ?? String(props.displays[0]?.id ?? "")} />
<input type="hidden" name="is_default" value={v["is_default"] ?? "0"} />
<input type="hidden" name="resets_idle_timer" value={v["resets_idle_timer"] ?? "1"} />
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
<strong>{p.label}</strong>
<div style="color:#666; font-size:0.8rem">{p.desc}</div>
</button>
</form>
))}
</div>
</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>.
{/* Full form */}
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Custom Layout</h2>
<form method="post" action="/admin/layouts/new">
<input type="hidden" name="preset" value="custom" />
<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="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>
{props.displays.length === 0 && (
<div class="form-hint">
No displays exist yet. Pair a kiosk first to create a display.
</div>
)}
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
<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"] ?? "1"} />
</div>
)}
</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"] ?? "1"} />
</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="regions">Regions (JSON)</label>
<textarea
id="regions"
name="regions"
class="form-input"
rows="6"
placeholder={'[\n { "name": "main", "row": 0, "col": 0, "rowSpan": 1, "colSpan": 1 }\n]'}
>{v["regions"] ?? ""}</textarea>
<div class="form-hint">
Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed.
</div>
</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="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 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="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>
<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>
<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>
</div>
</Layout>
);
@ -1251,7 +1077,6 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
interface LayoutEditPageProps {
user: string;
layout: LayoutType;
template: LayoutTemplate;
display: Display;
cells: LayoutCell[];
cameras: Camera[];
@ -1261,7 +1086,6 @@ interface LayoutEditPageProps {
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) {
@ -1326,17 +1150,17 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
<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>Grid: {String(l.grid_cols)}x{String(l.grid_rows)}, {String(l.regions.length)} region{l.regions.length !== 1 ? "s" : ""}</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 && (
{/* Grid preview with cell assignments */}
{l.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) => {
<div style={`display:grid; grid-template-columns:repeat(${String(l.grid_cols)}, 1fr); grid-template-rows:repeat(${String(l.grid_rows)}, 40px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
{l.regions.map((r) => {
const cell = cellByRegion.get(r.name);
let label = r.name;
let bgColor = "#f9fafb";
@ -1363,6 +1187,35 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
</div>
)}
{/* Regions table */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Regions</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Region</th>
<th>Position</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{l.regions.length === 0 ? (
<tr><td colspan="3" style="text-align:center; color:#999; padding:1rem">No regions defined</td></tr>
) : (
l.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>
{/* Cell assignments table */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Cell Assignments</h2>
@ -1376,7 +1229,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
</tr>
</thead>
<tbody>
{t.regions.map((r) => {
{l.regions.map((r) => {
const cell = cellByRegion.get(r.name);
return (
<tr>
@ -1422,7 +1275,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
<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) => {
{l.regions.map((r) => {
const taken = cellByRegion.has(r.name);
return (
<option value={r.name} disabled={taken}>
@ -1507,6 +1360,7 @@ interface DisplayEditPageProps {
user: string;
display: Display;
layouts: LayoutType[];
kioskName?: string | null;
error?: string;
success?: string;
}
@ -1530,7 +1384,9 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<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>
{d.kiosk_id && (
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
)}
</div>
<form method="post" action={`/admin/displays/${d.id}`}>
<div class="form-group">
@ -1618,7 +1474,7 @@ interface DisplaysPageProps {
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>
<p style="color:#666; margin-bottom:1.25rem">Physical HDMI displays. Created automatically when kiosks are paired.</p>
<div class="table-wrap">
<table>
<thead>

View file

@ -44,7 +44,6 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/" label="Overview" icon="&#9632;" active={a === "overview"} />
<NavItem href="/admin/cameras" label="Cameras" icon="&#9899;" active={a === "cameras"} />
<NavItem href="/admin/layouts" label="Layouts" icon="&#9638;" active={a === "layouts"} />
<NavItem href="/admin/templates" label="Templates" icon="&#9641;" active={a === "templates"} />
<NavItem href="/admin/displays" label="Displays" icon="&#9642;" active={a === "displays"} />
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />