refactor: Nx-Witness layout builder + drop regions/is_default

- Cells own position directly (row/col/row_span/col_span)
- Drop regions JSON from layouts (cells ARE the regions)
- Drop is_default from layouts (display.default_layout_id owns)
- Drop grid_cols/grid_rows from layouts (computed from cells)
- Layout new form: name, description, priority, resets_idle_timer only
- Layout edit: visual grid builder, + buttons on cell edges,
  click cell to assign content
- Bundle cells now carry position directly
- Rust kiosk attaches widgets using cell position
- Migration v0.4: backfills cell positions from old region map
This commit is contained in:
Mitchell R 2026-05-10 21:55:19 +02:00
parent 7fbda3c2b3
commit 533412a826
11 changed files with 3485 additions and 511 deletions

2821
kiosk/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,6 @@ pub struct BundleDisplay {
pub struct BundleLayout {
pub id: u32,
pub name: String,
pub regions: Vec<BundleRegion>,
pub grid_cols: u32,
pub grid_rows: u32,
pub priority: String,
@ -37,19 +36,11 @@ pub struct BundleLayout {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleRegion {
pub name: String,
pub struct BundleCell {
pub row: u32,
pub col: u32,
#[serde(rename = "rowSpan")]
pub row_span: u32,
#[serde(rename = "colSpan")]
pub col_span: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleCell {
pub region_name: String,
pub content_type: String,
pub camera_id: Option<u32>,
pub stream_selector: Option<String>,

View file

@ -120,8 +120,8 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
return;
};
if layout.regions.is_empty() {
warn!("layout has no regions");
if layout.cells.is_empty() {
warn!("layout has no cells");
show_logo(window);
return;
}
@ -141,12 +141,6 @@ 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 = layout.regions.iter().find(|r| r.name == cell.region_name);
let Some(region) = region else {
warn!("region '{}' not found in layout", cell.region_name);
continue;
};
let widget: gtk::Widget = match cell.content_type.as_str() {
"camera" => {
if let Some(cam_id) = cell.camera_id {
@ -192,30 +186,13 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
grid.attach(
&widget,
region.col as i32,
region.row as i32,
region.col_span as i32,
region.row_span as i32,
cell.col as i32,
cell.row as i32,
cell.col_span as i32,
cell.row_span as i32,
);
}
// Fill empty 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; }");
empty.set_vexpand(true);
empty.set_hexpand(true);
grid.attach(
&empty,
region.col as i32,
region.row as i32,
region.col_span as i32,
region.row_span as i32,
);
}
}
window.set_child(Some(&grid));
let pipelines_ref = pipelines.clone();

View file

@ -1,7 +1,7 @@
/**
* Admin page routes overview, cameras, kiosks, labels, etc.
*/
import { type H3, readBody, getRouterParam } from "h3";
import { type H3, readBody, getRouterParam, getQuery } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js";
import { confirmPairing } from "../../shared/pairing.js";
@ -19,7 +19,6 @@ import {
DisplaysPage,
DisplayEditPage,
} from "../../web-templates/admin-pages.js";
import type { Display } from "../../shared/types.js";
function sanitizeRtspUrl(raw: string): string {
const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/);
@ -198,105 +197,36 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/layouts", (event) => {
const user = event.context.user!;
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);
// For each layout, how many displays use it (for the list view).
const displayCounts = new Map<number, number>();
for (const l of layouts) {
displayCounts.set(l.id, deps.repo.listDisplaysForLayout(l.id).length);
}
return htmlPage(LayoutsPage({ user: user.username, layouts, displays }));
return htmlPage(LayoutsPage({ user: user.username, layouts, displayCounts }));
});
app.get("/admin/layouts/new", (event) => {
const user = event.context.user!;
return htmlPage(LayoutNewPage({
user: user.username,
displays: deps.repo.listDisplays(),
}));
return htmlPage(LayoutNewPage({ user: user.username }));
});
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";
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(displayId)) errors.push("Select a display.");
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;
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"] ?? "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 layout.");
} 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 (priority !== "hot" && priority !== "normal" && priority !== "cold") {
errors.push("Priority must be hot/normal/cold.");
}
if (errors.length > 0) {
return htmlPage(LayoutNewPage({
user: user.username,
displays: deps.repo.listDisplays(),
error: errors.join(" "),
values: body,
}));
@ -305,12 +235,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const layout = deps.repo.createLayout({
name,
description,
regions,
grid_cols: gridCols,
grid_rows: gridRows,
display_id: displayId,
priority,
is_default: isDefault,
resets_idle_timer: resetsIdleTimer,
});
@ -322,16 +247,19 @@ 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 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();
const displays = deps.repo.listDisplaysForLayout(id);
const q = getQuery(event) as Record<string, string | undefined>;
const selectedRaw = q["cell"];
const selectedCellId = selectedRaw ? Number(selectedRaw) : null;
return htmlPage(LayoutEditPage({
user: user.username,
layout,
display,
displays,
cells,
cameras,
selectedCellId: selectedCellId && cells.some((c) => c.id === selectedCellId) ? selectedCellId : null,
}));
});
@ -345,28 +273,93 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
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}` } });
});
// Create a new 1x1 cell. Two body shapes:
// { position: { row, col } } — explicit position, may shift others.
// { after_cell_id, direction } — relative to existing cell (right/below/left/above).
// Returns 302 redirect to the layout edit page (htmx will swap on hx-target).
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";
const body = await readBody<Record<string, string | number | { row: number; col: number }>>(event);
if (!regionName) {
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
let row = 0;
let col = 0;
const afterCellIdRaw = body?.["after_cell_id"];
const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : "";
if (afterCellIdRaw && direction) {
const afterId = Number(afterCellIdRaw);
const cells = deps.repo.layoutCells(layoutId);
const ref = cells.find((c) => c.id === afterId);
if (!ref) {
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
}
if (direction === "right") {
row = ref.row;
col = ref.col + ref.col_span;
} else if (direction === "bottom") {
row = ref.row + ref.row_span;
col = ref.col;
} else if (direction === "left") {
row = ref.row;
if (ref.col === 0) {
deps.repo.shiftCellsForLayout(layoutId, "col", 0, 1);
col = 0;
} else {
col = ref.col - 1;
}
} else if (direction === "above") {
col = ref.col;
if (ref.row === 0) {
deps.repo.shiftCellsForLayout(layoutId, "row", 0, 1);
row = 0;
} else {
row = ref.row - 1;
}
}
} else {
// Explicit position — accept top-level row/col or nested position.
const pos = body?.["position"];
if (pos && typeof pos === "object" && !Array.isArray(pos)) {
row = Number((pos as { row: number; col: number }).row) || 0;
col = Number((pos as { row: number; col: number }).col) || 0;
} else {
row = Number(body?.["row"] ?? 0) || 0;
col = Number(body?.["col"] ?? 0) || 0;
}
}
deps.repo.createLayoutCell({
layout_id: layoutId,
region_name: regionName,
row,
col,
row_span: 1,
col_span: 1,
content_type: "html",
html_content: null,
});
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
// Update a cell's content assignment.
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const body = await readBody<Record<string, string>>(event);
const contentType = (body?.["content_type"] ?? "html") as "camera" | "web" | "html";
deps.repo.updateLayoutCell(cellId, {
content_type: contentType,
camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null,
stream_selector: contentType === "camera" ? (body?.["stream_selector"] ?? "auto") : null,
stream_selector: contentType === "camera"
? ((body?.["stream_selector"] as "auto" | "main" | "sub") ?? "auto")
: "auto",
web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null,
html_content: contentType === "html" ? (body?.["html_content"] ?? null) : null,
});
@ -400,26 +393,64 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
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);
const attachedLayouts = deps.repo.listLayoutsForDisplay(id);
const attachedIds = new Set(attachedLayouts.map((l) => l.id));
const availableLayouts = deps.repo.listLayouts().filter((l) => !attachedIds.has(l.id));
const kiosk = display.kiosk_id ? deps.repo.getKioskById(display.kiosk_id) : null;
return htmlPage(DisplayEditPage({ user: user.username, display, layouts, kioskName: kiosk?.name ?? null }));
return htmlPage(DisplayEditPage({
user: user.username,
display,
attachedLayouts,
availableLayouts,
kioskName: kiosk?.name ?? null,
}));
});
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;
const defaultLayoutIdRaw = body?.["default_layout_id"];
const defaultLayoutId = defaultLayoutIdRaw ? Number(defaultLayoutIdRaw) : null;
// Validate default_layout_id is actually attached to this display.
let validatedDefault: number | null = defaultLayoutId;
if (defaultLayoutId != null) {
const attached = deps.repo.listLayoutsForDisplay(id);
if (!attached.some((l) => l.id === defaultLayoutId)) {
validatedDefault = null;
}
}
// width/height are no longer admin-editable — they come from the kiosk's
// hardware report. Just update the editable fields.
deps.repo.updateDisplay(id, {
name: body?.["name"],
default_layout_id: defaultLayoutId,
default_layout_id: validatedDefault,
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}` } });
});
// Attach a layout to a display.
app.post("/admin/displays/:id/layouts", async (event) => {
const displayId = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
const layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null;
if (layoutId && Number.isFinite(layoutId)) {
deps.repo.attachLayoutToDisplay(displayId, layoutId);
}
return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
});
// Detach a layout from a display.
app.post("/admin/displays/:id/layouts/:layoutId/remove", (event) => {
const displayId = Number(getRouterParam(event, "id"));
const layoutId = Number(getRouterParam(event, "layoutId"));
deps.repo.detachLayoutFromDisplay(displayId, layoutId);
return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
});
app.get("/admin/labels", (event) => {
const user = event.context.user!;
return htmlPage(LabelsPage({ user: user.username, labels: deps.repo.listLabels() }));

View file

@ -180,7 +180,7 @@ export function rowToLayout(r: Row): Layout {
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"]),
display_id: nn(r["display_id"]),
priority: s(r["priority"]) as LayoutPriority,
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []),
@ -194,6 +194,10 @@ export function rowToLayoutCell(r: Row): LayoutCell {
id: n(r["id"]),
layout_id: n(r["layout_id"]),
region_name: s(r["region_name"]),
row: n(r["row"]),
col: n(r["col"]),
row_span: n(r["row_span"]) || 1,
col_span: n(r["col_span"]) || 1,
content_type: s(r["content_type"]) as CellContentType,
camera_id: nn(r["camera_id"]),
stream_selector: s(r["stream_selector"]) as StreamSelector,

View file

@ -284,4 +284,86 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
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)`,
// ---- v0.3: decouple layouts from displays via join table -------------------
// Layouts become standalone entities; displays maintain a list of available
// layouts via display_layouts. Old layouts.display_id column is kept (SQLite
// can't drop columns) but no longer used by the application.
`CREATE TABLE IF NOT EXISTS display_layouts (
display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
PRIMARY KEY (display_id, layout_id)
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
(db: DatabaseSync) => {
// Backfill: every existing layout that has display_id gets attached to
// that display via the new join table. Idempotent via INSERT OR IGNORE.
const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === "display_id")) return;
const rows = db
.prepare(`SELECT id, display_id FROM layouts WHERE display_id IS NOT NULL`)
.all() as Array<{ id: number; display_id: number | null }>;
const ins = db.prepare(
`INSERT OR IGNORE INTO display_layouts (display_id, layout_id) VALUES (?, ?)`,
);
for (const r of rows) {
if (r.display_id != null) ins.run(r.display_id, r.id);
}
},
// ---- v0.4: cells own their position; drop regions/grid_*/is_default ----------
// layout_cells now have row/col/row_span/col_span columns directly. Existing
// cells get backfilled by parsing layouts.regions JSON and matching on
// region_name. The old columns (regions, grid_cols, grid_rows, is_default,
// region_name) are kept on the row (SQLite can't drop columns) but no longer
// used by the application.
(db: DatabaseSync) => {
addColumnIfNotExists(db, "layout_cells", "row", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists(db, "layout_cells", "col", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists(db, "layout_cells", "row_span", "INTEGER NOT NULL DEFAULT 1");
addColumnIfNotExists(db, "layout_cells", "col_span", "INTEGER NOT NULL DEFAULT 1");
// Backfill: parse each layout's regions JSON, match cells by region_name,
// copy row/col/rowSpan/colSpan onto the cell row. Only update cells that
// still have the default 0,0,1,1 (idempotent re-runs become no-ops once the
// operator has edited cells through the new UI).
const cellCols = db.prepare(`PRAGMA table_info("layout_cells")`).all() as Array<{ name: string }>;
const hasRegionName = cellCols.some((c) => c.name === "region_name");
const layoutCols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
const hasRegions = layoutCols.some((c) => c.name === "regions");
if (!hasRegionName || !hasRegions) return;
const layouts = db
.prepare(`SELECT id, regions FROM layouts WHERE regions IS NOT NULL AND regions != '[]'`)
.all() as Array<{ id: number; regions: string }>;
const updateCell = db.prepare(
`UPDATE layout_cells
SET row = ?, col = ?, row_span = ?, col_span = ?
WHERE id = ?
AND row = 0 AND col = 0 AND row_span = 1 AND col_span = 1`,
);
for (const l of layouts) {
let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }>;
try {
regions = JSON.parse(l.regions);
} catch {
continue;
}
if (!Array.isArray(regions)) continue;
const cells = db
.prepare(`SELECT id, region_name FROM layout_cells WHERE layout_id = ?`)
.all(l.id) as Array<{ id: number; region_name: string }>;
for (const c of cells) {
const r = regions.find((reg) => reg.name === c.region_name);
if (!r) continue;
updateCell.run(
Number(r.row) || 0,
Number(r.col) || 0,
Number(r.rowSpan) || 1,
Number(r.colSpan) || 1,
c.id,
);
}
}
},
];

View file

@ -484,42 +484,87 @@ export class Repository {
return r ? rowToLayout(r as Record<string, unknown>) : null;
}
/**
* @deprecated Use `listLayoutsForDisplay` which goes through the
* `display_layouts` join table. Kept as a thin alias for any
* callers still on the old API.
*/
layoutsForDisplay(displayId: number): Layout[] {
return this.listLayoutsForDisplay(displayId);
}
/** All layouts attached to the given display, via display_layouts. */
listLayoutsForDisplay(displayId: number): Layout[] {
const rs = this.prep(
"SELECT * FROM layouts WHERE display_id = ? ORDER BY name",
`SELECT l.* FROM layouts l
JOIN display_layouts dl ON dl.layout_id = l.id
WHERE dl.display_id = ?
ORDER BY l.name`,
).all(displayId);
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
}
/** Inverse: all displays that have this layout attached. */
listDisplaysForLayout(layoutId: number): Display[] {
const rs = this.prep(
`SELECT d.* FROM displays d
JOIN display_layouts dl ON dl.display_id = d.id
WHERE dl.layout_id = ?
ORDER BY d."index"`,
).all(layoutId);
return rs.map((r) => rowToDisplay(r as Record<string, unknown>));
}
/** Idempotent attach. */
attachLayoutToDisplay(displayId: number, layoutId: number): void {
this.prep(
`INSERT OR IGNORE INTO display_layouts (display_id, layout_id)
VALUES (?, ?)`,
).run(displayId, layoutId);
void this.notify("display_layouts", "create", layoutId);
}
/** Detach. If the display's default_layout_id pointed at this layout, clear it. */
detachLayoutFromDisplay(displayId: number, layoutId: number): void {
this.db
.prepare(`DELETE FROM display_layouts WHERE display_id = ? AND layout_id = ?`)
.run(displayId, layoutId);
this.db
.prepare(
`UPDATE displays SET default_layout_id = NULL
WHERE id = ? AND default_layout_id = ?`,
)
.run(displayId, layoutId);
void this.notify("display_layouts", "delete", layoutId);
}
createLayout(input: {
name: string;
description?: string | null;
template_id?: number | null;
regions: unknown;
grid_cols: number;
grid_rows: number;
display_id: number;
priority?: string;
cooling_timeout_seconds?: number | null;
preload_camera_ids?: number[];
is_default?: boolean;
resets_idle_timer?: boolean;
}): Layout {
// Legacy NOT NULL columns (template_id, display_id, regions, grid_*) are
// populated with sentinel values: cells own their position now and the
// grid is computed at read time. The columns will be dropped by a future
// migration — until then they're inert.
const result = this.prep(
`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 ?? null,
J(input.regions),
input.grid_cols,
input.grid_rows,
input.display_id,
null,
J([]),
1,
1,
0,
input.priority ?? "normal",
input.cooling_timeout_seconds ?? null,
J(input.preload_camera_ids ?? []),
B(input.is_default ?? false),
B(false),
B(input.resets_idle_timer ?? true),
);
const id = Number(result.lastInsertRowid);
@ -533,7 +578,7 @@ export class Repository {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id") continue;
if (k === "id" || k === "display_id") continue; // display_id deprecated
sets.push(`${k} = ?`);
if (k === "preload_camera_ids" || k === "regions") vals.push(J(v));
else if (typeof v === "boolean") vals.push(B(v));
@ -548,6 +593,9 @@ export class Repository {
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 display_layouts WHERE layout_id = ?`).run(id);
// Any display whose default pointed here gets cleared.
this.db.prepare(`UPDATE displays SET default_layout_id = NULL WHERE default_layout_id = ?`).run(id);
this.db.prepare(`DELETE FROM layouts WHERE id = ?`).run(id);
void this.notify("layouts", "delete", id);
}
@ -558,7 +606,10 @@ export class Repository {
createLayoutCell(input: {
layout_id: number;
region_name: string;
row: number;
col: number;
row_span?: number;
col_span?: number;
content_type: string;
camera_id?: number | null;
stream_selector?: string | null;
@ -567,12 +618,19 @@ export class Repository {
cooling_timeout_seconds?: number | null;
options?: Record<string, unknown>;
}): LayoutCell {
// region_name column is legacy NOT NULL — synthesize a unique placeholder
// until the column is dropped by a future migration. Nothing reads it.
const placeholder = `cell_${input.layout_id}_${input.row}_${input.col}_${Date.now()}`;
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO layout_cells (layout_id, region_name, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.layout_id,
input.region_name,
placeholder,
input.row,
input.col,
input.row_span ?? 1,
input.col_span ?? 1,
input.content_type,
input.camera_id ?? null,
input.stream_selector ?? "auto",
@ -593,7 +651,8 @@ export class Repository {
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "layout_id") continue;
sets.push(`${k} = ?`);
const col = k === "row" ? `"row"` : k;
sets.push(`${col} = ?`);
if (k === "options") vals.push(J(v));
else vals.push(v === undefined ? null : v);
}
@ -608,15 +667,45 @@ export class Repository {
void this.notify("layout_cells", "delete", id);
}
/**
* Shift cells along an axis to make room for an insertion (or close a gap
* after a deletion). For axis="row", any cell whose `row >= fromIndex` has
* its row bumped by `delta`. Same for axis="col". Used by the visual
* builder when adding a cell to the top/left of an existing one.
*/
shiftCellsForLayout(
layoutId: number,
axis: "row" | "col",
fromIndex: number,
delta: number,
): void {
if (delta === 0) return;
const colName = axis === "row" ? `"row"` : "col";
this.db
.prepare(
`UPDATE layout_cells
SET ${colName} = ${colName} + ?
WHERE layout_id = ?
AND ${colName} >= ?`,
)
.run(delta, layoutId, fromIndex);
void this.notify("layout_cells", "update", layoutId);
}
listLayoutCells(layoutId: number): LayoutCell[] {
const rs = this.prep(
`SELECT * FROM layout_cells WHERE layout_id = ? ORDER BY "row", col`,
).all(layoutId);
return rs.map((r) => rowToLayoutCell(r as Record<string, unknown>));
}
// ===========================================================================
// display-chain bundle queries (kiosk → display → layouts → cells → cameras)
// ===========================================================================
/** Bundle generation: layouts attached to a display via display_layouts. */
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>));
return this.listLayoutsForDisplay(displayId);
}
camerasForLayoutIds(layoutIds: number[]): Camera[] {
@ -1064,10 +1153,7 @@ export class Repository {
}
layoutCells(layoutId: number): LayoutCell[] {
const rs = this.prep(
"SELECT * FROM layout_cells WHERE layout_id = ?",
).all(layoutId);
return rs.map((r) => rowToLayoutCell(r as Record<string, unknown>));
return this.listLayoutCells(layoutId);
}
layoutTemplates(ids: number[]): LayoutTemplate[] {

View file

@ -58,27 +58,18 @@ const camera = av.object(
{ unknownKeys: "reject" },
);
const layoutRegion = 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" },
);
const layoutCell = av.object(
{
region_name: av.string().minLength(1).maxLength(64),
row: av.int().min(0).max(63),
col: av.int().min(0).max(63),
row_span: av.int().min(1).max(64),
col_span: av.int().min(1).max(64),
content_type: cellContentType,
camera_id: av.nullable(av.int().min(1)),
stream_selector: streamSelector,
web_url: av.nullable(av.string()),
html_content: av.nullable(av.string()),
cooling_timeout_seconds: av.nullable(av.int().min(0)),
options: av.record(av.unknown()),
},
{ unknownKeys: "reject" },
);
@ -87,14 +78,13 @@ const layout = av.object(
{
id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
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)),
is_default: av.bool(),
resets_idle_timer: av.bool(),
is_default: av.bool(),
cells: av.array(layoutCell),
},
{ unknownKeys: "reject" },

View file

@ -31,7 +31,10 @@ export interface BundleCamera {
}
export interface BundleCell {
region_name: string;
row: number;
col: number;
row_span: number;
col_span: number;
content_type: string;
camera_id: number | null;
stream_selector: string | null;
@ -43,14 +46,16 @@ export interface BundleCell {
export interface BundleLayout {
id: number;
name: string;
regions: unknown;
/** Computed from cells: max(col + col_span). 1 if no cells. */
grid_cols: number;
/** Computed from cells: max(row + row_span). 1 if no cells. */
grid_rows: number;
priority: string;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
is_default: boolean;
resets_idle_timer: boolean;
/** True if the kiosk's display has this layout as its default_layout_id. */
is_default: boolean;
cells: BundleCell[];
}
@ -97,21 +102,32 @@ export function generateBundle(
// Collect all cameras referenced by cells in these layouts
const cameras = repo.camerasForLayoutIds(layoutIds);
const defaultLayoutId = display.default_layout_id;
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
const cells = repo.layoutCells(l.id);
let gridCols = 1;
let gridRows = 1;
for (const c of cells) {
const right = c.col + c.col_span;
const bottom = c.row + c.row_span;
if (right > gridCols) gridCols = right;
if (bottom > gridRows) gridRows = bottom;
}
return {
id: l.id,
name: l.name,
regions: l.regions,
grid_cols: l.grid_cols,
grid_rows: l.grid_rows,
grid_cols: gridCols,
grid_rows: gridRows,
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,
is_default: defaultLayoutId === l.id,
cells: cells.map((c) => ({
region_name: c.region_name,
row: c.row,
col: c.col,
row_span: c.row_span,
col_span: c.col_span,
content_type: c.content_type,
camera_id: c.camera_id,
stream_selector: c.stream_selector,

View file

@ -141,13 +141,19 @@ export interface Layout {
name: string;
description: string | null;
template_id: number | null; // deprecated — kept nullable for backward compat
/** @deprecated Cells now own their own position. Computed from cells at read time. */
regions: LayoutRegion[];
/** @deprecated Computed from cells: max(col + col_span). */
grid_cols: number;
/** @deprecated Computed from cells: max(row + row_span). */
grid_rows: number;
display_id: number;
/** @deprecated Layouts are now standalone; use display_layouts join table.
* Column kept on the row for backward compat will be removed in a future migration. */
display_id: number | null;
priority: LayoutPriority;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
/** @deprecated Per-display defaults live on `display.default_layout_id`. */
is_default: boolean;
resets_idle_timer: boolean;
}
@ -155,7 +161,12 @@ export interface Layout {
export interface LayoutCell {
id: number;
layout_id: number;
/** @deprecated Cells own their position via row/col/row_span/col_span now. */
region_name: string;
row: number;
col: number;
row_span: number;
col_span: number;
content_type: CellContentType;
camera_id: number | null;
stream_selector: StreamSelector;

View file

@ -10,7 +10,6 @@ import type {
Label,
Layout as LayoutType,
LayoutCell,
LayoutRegion,
PairingCode,
EventLog,
} from "../shared/types.js";
@ -879,7 +878,8 @@ export function LabelsPage(props: LabelsPageProps) {
interface LayoutsPageProps {
user: string;
layouts: LayoutType[];
displays: Map<number, Display>;
/** layout_id → number of displays the layout is attached to */
displayCounts: Map<number, number>;
}
export function LayoutsPage(props: LayoutsPageProps) {
@ -890,30 +890,33 @@ 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 defines a grid of regions and binds cameras or other content into them for a display.
Layouts are standalone they define a grid of regions and bind cameras or
other content into them. Attach a layout to one or more displays from the
display's edit page.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Grid</th>
<th>Display</th>
<th>Displays</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>
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No layouts created yet</td></tr>
) : (
props.layouts.map((l) => {
const disp = props.displays.get(l.display_id);
const count = props.displayCounts.get(l.id) ?? 0;
return (
<tr>
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></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>
{count === 0
? <span style="color:#999">unattached</span>
: <span>{String(count)} display{count !== 1 ? "s" : ""}</span>}
</td>
<td>
{l.priority === "hot"
? <span class="badge badge-red">hot</span>
@ -922,9 +925,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
: <span class="badge badge-gray">normal</span>
}
</td>
<td>
{l.is_default ? <span class="badge badge-green">Yes</span> : ""}
</td>
</tr>
);
})
@ -940,7 +940,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
interface LayoutNewPageProps {
user: string;
displays: Display[];
error?: string;
values?: Record<string, string>;
}
@ -954,85 +953,21 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
activeNav="layouts"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<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>
{/* Full form */}
<div style="max-width:600px">
<p style="color:#666; margin-bottom:1.25rem">
Create an empty layout. You'll add cells visually on the next page,
then attach the layout to one or more displays.
</p>
<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 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="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>
<label for="description">Description (optional)</label>
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
</div>
<div class="form-group">
@ -1044,18 +979,6 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
</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"} />
@ -1077,26 +1000,67 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
interface LayoutEditPageProps {
user: string;
layout: LayoutType;
display: Display;
/** Displays this layout is attached to (informational, read-only). */
displays: Display[];
cells: LayoutCell[];
cameras: Camera[];
/** If set, render the content-assignment form for this cell beneath the grid. */
selectedCellId?: number | null;
error?: string;
success?: string;
}
const LAYOUT_BUILDER_CSS = `
.layout-builder { display: grid; gap: 4px; aspect-ratio: 16/9; max-width: 100%; background: #ddd; padding: 4px; border-radius: 4px; }
.layout-cell { background: #fff; border: 2px solid #2563eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; min-height: 60px; padding: 4px; text-align: center; font-size: 0.85rem; font-weight: 600; color: #1e40af; overflow: hidden; }
.layout-cell:hover { background: #f0f7ff; }
.layout-cell.selected { background: #dbeafe; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; }
.layout-cell-empty-text { color: #999; font-size: 0.75rem; font-weight: 400; }
.layout-cell-add { position: absolute; background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; opacity: 0; transition: opacity 0.2s; padding: 0; z-index: 2; }
.layout-cell:hover .layout-cell-add { opacity: 1; }
.layout-cell-add:hover { background: #1e40af; opacity: 1; }
.layout-cell-add-top { top: -12px; left: 50%; transform: translateX(-50%); }
.layout-cell-add-right { right: -12px; top: 50%; transform: translateY(-50%); }
.layout-cell-add-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); }
.layout-cell-add-left { left: -12px; top: 50%; transform: translateY(-50%); }
.layout-cell-delete { position: absolute; top: 4px; right: 4px; background: rgba(220, 38, 38, 0.9); color: #fff; border: none; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; font-size: 12px; line-height: 1; padding: 0; opacity: 0; transition: opacity 0.2s; z-index: 2; }
.layout-cell:hover .layout-cell-delete { opacity: 1; }
.layout-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/9; background: #f3f4f6; border-radius: 4px; }
.layout-empty-add { background: #2563eb; color: #fff; border: none; width: 80px; height: 80px; border-radius: 50%; cursor: pointer; font-size: 36px; line-height: 1; padding: 0; }
.layout-empty-add:hover { background: #1e40af; }
`;
export function LayoutEditPage(props: LayoutEditPageProps) {
const l = props.layout;
// 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 cells = props.cells;
const cameraById = new Map<number, Camera>();
for (const cam of props.cameras) {
cameraById.set(cam.id, cam);
}
// Compute grid dimensions from cells.
let gridCols = 1;
let gridRows = 1;
for (const c of cells) {
const right = c.col + c.col_span;
const bottom = c.row + c.row_span;
if (right > gridCols) gridCols = right;
if (bottom > gridRows) gridRows = bottom;
}
const selectedCell = props.selectedCellId
? cells.find((c) => c.id === props.selectedCellId) ?? null
: null;
function cellLabel(c: LayoutCell): string {
if (c.content_type === "camera" && c.camera_id) {
return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`;
}
if (c.content_type === "web") return c.web_url ? `Web: ${c.web_url}` : "Web";
if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)";
return "Empty";
}
return (
<Layout
title={`Layout: ${l.name}`}
@ -1108,7 +1072,8 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
: undefined
}
>
<div style="max-width:800px">
<style>{LAYOUT_BUILDER_CSS}</style>
<div style="max-width:900px">
{/* Settings */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Settings</h2>
@ -1134,12 +1099,6 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
<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} />
@ -1150,202 +1109,181 @@ 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>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>Grid: {String(gridCols)}x{String(gridRows)}, {String(cells.length)} cell{cells.length !== 1 ? "s" : ""}</div>
<div>
{props.displays.length === 0
? <span>Attached to no displays attach from a display's edit page.</span>
: (
<span>
Attached to:{" "}
{props.displays.map((d, i) => (
<span>
{i > 0 ? ", " : ""}
<a href={`/admin/displays/${d.id}`}>{d.name}</a>
</span>
))}
</span>
)}
</div>
</div>
</div>
{/* 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(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";
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";
}
}
{/* Visual builder */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layout Builder</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
Hover a cell to see <strong>+</strong> buttons (add a neighbour) and the <strong>×</strong> delete button.
Click a cell to assign content.
</p>
{cells.length === 0 ? (
<div class="layout-empty">
<form method="post" action={`/admin/layouts/${l.id}/cells`} style="margin:0">
<input type="hidden" name="row" value="0" />
<input type="hidden" name="col" value="0" />
<button type="submit" class="layout-empty-add" title="Add first cell">+</button>
</form>
</div>
) : (
<div class="layout-builder" style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}>
{cells.map((c) => {
const isSelected = selectedCell?.id === c.id;
const cellStyle = `grid-column:${String(c.col + 1)} / span ${String(c.col_span)}; grid-row:${String(c.row + 1)} / span ${String(c.row_span)};`;
const isEmpty = c.content_type === "html" && !c.html_content
|| c.content_type === "camera" && !c.camera_id
|| c.content_type === "web" && !c.web_url;
return (
<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>
<a
href={`/admin/layouts/${l.id}?cell=${String(c.id)}`}
class={`layout-cell${isSelected ? " selected" : ""}`}
style={cellStyle}
>
{isEmpty
? <span class="layout-cell-empty-text">{cellLabel(c)}</span>
: <span>{cellLabel(c)}</span>
}
{/* + buttons (4 sides) */}
{(["top", "right", "bottom", "left"] as const).map((dir) => {
const directionParam = dir === "top" ? "above" : dir;
return (
<form
method="post"
action={`/admin/layouts/${l.id}/cells`}
{...{"onclick": "event.stopPropagation()"}}
style="display:contents"
>
<input type="hidden" name="after_cell_id" value={String(c.id)} />
<input type="hidden" name="direction" value={directionParam} />
<button
type="submit"
class={`layout-cell-add layout-cell-add-${dir}`}
title={`Add cell ${dir}`}
>+</button>
</form>
);
})}
{/* delete button */}
<form
method="post"
action={`/admin/layouts/${l.id}/cells/${String(c.id)}/delete`}
{...{"onclick": "event.stopPropagation()"}}
style="display:contents"
>
<button
type="submit"
class="layout-cell-delete"
title="Delete cell"
{...{"onclick": "event.stopPropagation(); return confirm('Delete this cell?')"}}
>×</button>
</form>
</a>
);
})}
</div>
)}
</div>
{/* Selected-cell content form */}
{selectedCell && (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">
Cell at row {String(selectedCell.row)}, col {String(selectedCell.col)}
{" "}<span style="font-weight:400; color:#666; font-size:0.85rem">({String(selectedCell.row_span)}x{String(selectedCell.col_span)})</span>
</h2>
<form method="post" action={`/admin/layouts/${l.id}/cells/${String(selectedCell.id)}`}>
<div class="form-group">
<label>Content Type</label>
<div class="radio-group">
<label>
<input type="radio" name="content_type" value="camera" checked={selectedCell.content_type === "camera"} />
{" "}Camera
</label>
<label>
<input type="radio" name="content_type" value="web" checked={selectedCell.content_type === "web"} />
{" "}Web URL
</label>
<label>
<input type="radio" name="content_type" value="html" checked={selectedCell.content_type === "html"} />
{" "}HTML
</label>
</div>
</div>
<div id="cell-camera-fields" style={selectedCell.content_type === "camera" ? "" : "display:none"}>
<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)} selected={selectedCell.camera_id === 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" selected={selectedCell.stream_selector === "auto"}>Auto</option>
<option value="main" selected={selectedCell.stream_selector === "main"}>Main</option>
<option value="sub" selected={selectedCell.stream_selector === "sub"}>Sub</option>
</select>
</div>
</div>
<div id="cell-web-fields" style={selectedCell.content_type === "web" ? "" : "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" value={selectedCell.web_url ?? ""} />
</div>
</div>
<div id="cell-html-fields" style={selectedCell.content_type === "html" ? "" : "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>">{selectedCell.html_content ?? ""}</textarea>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Cell</button>
<a href={`/admin/layouts/${l.id}`} class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
<form method="post" action={`/admin/layouts/${l.id}/cells/${String(selectedCell.id)}/delete`} style="display:inline; margin-left:0.5rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this cell?')"}}>Delete Cell</button>
</form>
</form>
<script>{js(
`(function(){` +
`var rs=document.querySelectorAll('input[name="content_type"]');` +
`var cf=document.getElementById("cell-camera-fields");` +
`var wf=document.getElementById("cell-web-fields");` +
`var hf=document.getElementById("cell-html-fields");` +
`function t(){var el=document.querySelector('input[name="content_type"]:checked');` +
`var v=el?el.value:"html";` +
`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";}` +
`rs.forEach(function(r){r.addEventListener("change",t)});t();})()`
)}</script>
</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>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Region</th>
<th>Content</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{l.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>
{l.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>
@ -1359,7 +1297,10 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
interface DisplayEditPageProps {
user: string;
display: Display;
layouts: LayoutType[];
/** Layouts currently attached to this display. */
attachedLayouts: LayoutType[];
/** All other layouts that could be attached. */
availableLayouts: LayoutType[];
kioskName?: string | null;
error?: string;
success?: string;
@ -1383,7 +1324,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<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>Resolution: {String(d.width_px)}x{String(d.height_px)} <span style="color:#999">(reported by kiosk)</span></div>
{d.kiosk_id && (
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
)}
@ -1398,13 +1339,15 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<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) => (
{props.attachedLayouts.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 class="form-hint">
Layout shown on idle revert. Only layouts attached below are eligible.
</div>
</div>
<div class="form-group">
@ -1419,46 +1362,68 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<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">
{/* Layout attachments */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Available Layouts</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
Pick which layouts this display can show. The kiosk receives only
attached layouts in its bundle.
</p>
{props.attachedLayouts.length === 0 ? (
<p style="color:#999; margin-bottom:1rem">No layouts attached yet.</p>
) : (
<div class="table-wrap" style="margin-bottom:1rem">
<table>
<thead>
<tr>
<th>Name</th>
<th>Priority</th>
<th>Default</th>
<th></th>
</tr>
</thead>
<tbody>
{props.layouts.map((l) => (
{props.attachedLayouts.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>
<td>{d.default_layout_id === l.id ? <span class="badge badge-green">Yes</span> : ""}</td>
<td>
<form method="post" action={`/admin/displays/${d.id}/layouts/${l.id}/remove`} style="display:inline">
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Detach this layout from the display?')"}}>Detach</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
)}
{props.availableLayouts.length > 0 ? (
<form method="post" action={`/admin/displays/${d.id}/layouts`} style="display:flex; gap:0.5rem">
<select name="layout_id" class="form-input" style="flex:1" required>
<option value="">-- Pick a layout to attach --</option>
{props.availableLayouts.map((l) => (
<option value={String(l.id)}>{l.name}</option>
))}
</select>
<button type="submit" class="btn btn-primary">Attach</button>
</form>
) : (
<p style="color:#999; font-size:0.85rem; margin:0">
{props.attachedLayouts.length === 0
? <span>No layouts exist yet. <a href="/admin/layouts/new">Create one</a>.</span>
: "All existing layouts are already attached."}
</p>
)}
</div>
</div>
</Layout>
);