mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat: layout/template/display CRUD + display-chain bundle routing
Major changes: - Bundle now follows kiosk → display → layouts → cells → cameras (no label filtering for v0.1) - Setup creates default Fullscreen template + Default layout with BetterFrame logo on the primary display - Pairing auto-assigns kiosk to primary display - Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3) - Admin UI: layout CRUD with cell management (assign cameras/web/html to template regions) - Admin UI: display editing (default layout, idle/sleep timeouts) - Repository: added createLayoutTemplate, createLayout, createLayoutCell, updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds, updateDisplay, and more
This commit is contained in:
parent
7b4a11c182
commit
cc306cec57
6 changed files with 1455 additions and 97 deletions
|
|
@ -13,8 +13,16 @@ import {
|
||||||
KiosksPage,
|
KiosksPage,
|
||||||
KioskEditPage,
|
KioskEditPage,
|
||||||
LabelsPage,
|
LabelsPage,
|
||||||
SimpleListPage,
|
TemplatesPage,
|
||||||
|
TemplateNewPage,
|
||||||
|
TemplateEditPage,
|
||||||
|
LayoutsPage,
|
||||||
|
LayoutNewPage,
|
||||||
|
LayoutEditPage,
|
||||||
|
DisplaysPage,
|
||||||
|
DisplayEditPage,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
|
import type { LayoutTemplate, Display } from "../../shared/types.js";
|
||||||
|
|
||||||
export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// ---- Overview -------------------------------------------------------------
|
// ---- Overview -------------------------------------------------------------
|
||||||
|
|
@ -167,43 +175,305 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Simple list pages (templates, layouts, displays, labels) -------------
|
// ---- Templates (Layout Templates) ------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/templates", (event) => {
|
app.get("/admin/templates", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
return htmlPage(SimpleListPage({
|
const templates = deps.repo.listLayoutTemplates();
|
||||||
user: user.username,
|
return htmlPage(TemplatesPage({ user: user.username, templates }));
|
||||||
pageTitle: "Layout Templates",
|
|
||||||
description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.",
|
|
||||||
activeNav: "templates",
|
|
||||||
items: [], // TODO: list templates
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/admin/templates/new", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return htmlPage(TemplateNewPage({ user: user.username }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/templates/new", async (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const preset = body?.["preset"] ?? "custom";
|
||||||
|
let name = (body?.["name"] ?? "").trim();
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!name || name.length > 128) {
|
||||||
|
errors.push("Name required (max 128 chars).");
|
||||||
|
}
|
||||||
|
|
||||||
|
let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }> = [];
|
||||||
|
let gridCols = 12;
|
||||||
|
let gridRows = 12;
|
||||||
|
|
||||||
|
if (preset === "fullscreen") {
|
||||||
|
gridCols = 1;
|
||||||
|
gridRows = 1;
|
||||||
|
regions = [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }];
|
||||||
|
} else if (preset === "2x2") {
|
||||||
|
gridCols = 2;
|
||||||
|
gridRows = 2;
|
||||||
|
regions = [
|
||||||
|
{ name: "tl", row: 0, col: 0, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "tr", row: 0, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "bl", row: 1, col: 0, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "br", row: 1, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
];
|
||||||
|
} else if (preset === "1plus3") {
|
||||||
|
gridCols = 2;
|
||||||
|
gridRows = 3;
|
||||||
|
regions = [
|
||||||
|
{ name: "main", row: 0, col: 0, rowSpan: 3, colSpan: 1 },
|
||||||
|
{ name: "r1", row: 0, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r2", row: 1, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r3", row: 2, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
];
|
||||||
|
} else if (preset === "3x3") {
|
||||||
|
gridCols = 3;
|
||||||
|
gridRows = 3;
|
||||||
|
regions = [
|
||||||
|
{ name: "r1", row: 0, col: 0, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r2", row: 0, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r3", row: 0, col: 2, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r4", row: 1, col: 0, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r5", row: 1, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r6", row: 1, col: 2, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r7", row: 2, col: 0, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r8", row: 2, col: 1, rowSpan: 1, colSpan: 1 },
|
||||||
|
{ name: "r9", row: 2, col: 2, rowSpan: 1, colSpan: 1 },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Custom
|
||||||
|
gridCols = parseInt(body?.["grid_cols"] ?? "12", 10);
|
||||||
|
gridRows = parseInt(body?.["grid_rows"] ?? "12", 10);
|
||||||
|
if (isNaN(gridCols) || gridCols < 1 || gridCols > 12) errors.push("Grid columns must be 1-12.");
|
||||||
|
if (isNaN(gridRows) || gridRows < 1 || gridRows > 12) errors.push("Grid rows must be 1-12.");
|
||||||
|
|
||||||
|
const regionsStr = (body?.["regions"] ?? "").trim();
|
||||||
|
if (!regionsStr) {
|
||||||
|
errors.push("Regions JSON is required for custom templates.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
regions = JSON.parse(regionsStr);
|
||||||
|
if (!Array.isArray(regions) || regions.length === 0) {
|
||||||
|
errors.push("Regions must be a non-empty JSON array.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors.push("Invalid JSON in regions field.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return htmlPage(TemplateNewPage({
|
||||||
|
user: user.username,
|
||||||
|
error: errors.join(" "),
|
||||||
|
values: body,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.repo.createLayoutTemplate({
|
||||||
|
name,
|
||||||
|
regions,
|
||||||
|
grid_cols: gridCols,
|
||||||
|
grid_rows: gridRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/templates/:id", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const template = deps.repo.getLayoutTemplateById(id);
|
||||||
|
if (!template) return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
|
||||||
|
return htmlPage(TemplateEditPage({ user: user.username, template }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/templates/:id", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
deps.repo.updateLayoutTemplate(id, {
|
||||||
|
name: body?.["name"],
|
||||||
|
description: body?.["description"] || null,
|
||||||
|
});
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/templates/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/templates/:id/delete", (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
deps.repo.deleteLayoutTemplate(id);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Layouts ---------------------------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/layouts", (event) => {
|
app.get("/admin/layouts", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
return htmlPage(SimpleListPage({
|
const layouts = deps.repo.listLayouts();
|
||||||
|
const templateIds = [...new Set(layouts.map((l) => l.template_id))];
|
||||||
|
const displayIds = [...new Set(layouts.map((l) => l.display_id))];
|
||||||
|
const templates = new Map<number, LayoutTemplate>();
|
||||||
|
for (const tid of templateIds) {
|
||||||
|
const t = deps.repo.getLayoutTemplateById(tid);
|
||||||
|
if (t) templates.set(tid, t);
|
||||||
|
}
|
||||||
|
const displays = new Map<number, Display>();
|
||||||
|
for (const did of displayIds) {
|
||||||
|
const d = deps.repo.getDisplayById(did);
|
||||||
|
if (d) displays.set(did, d);
|
||||||
|
}
|
||||||
|
return htmlPage(LayoutsPage({ user: user.username, layouts, templates, displays }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/layouts/new", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return htmlPage(LayoutNewPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
pageTitle: "Layouts",
|
templates: deps.repo.listLayoutTemplates(),
|
||||||
description: "A layout binds cameras and other content into a template's regions for one display.",
|
displays: deps.repo.listDisplays(),
|
||||||
activeNav: "layouts",
|
|
||||||
items: [], // TODO: list layouts
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/layouts/new", async (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const name = (body?.["name"] ?? "").trim();
|
||||||
|
const templateId = parseInt(body?.["template_id"] ?? "", 10);
|
||||||
|
const displayId = parseInt(body?.["display_id"] ?? "", 10);
|
||||||
|
const priority = body?.["priority"] ?? "normal";
|
||||||
|
const description = (body?.["description"] ?? "").trim() || null;
|
||||||
|
const isDefault = body?.["is_default"] === "1";
|
||||||
|
const resetsIdleTimer = body?.["resets_idle_timer"] === "1";
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
|
||||||
|
if (isNaN(templateId)) errors.push("Select a template.");
|
||||||
|
if (isNaN(displayId)) errors.push("Select a display.");
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return htmlPage(LayoutNewPage({
|
||||||
|
user: user.username,
|
||||||
|
templates: deps.repo.listLayoutTemplates(),
|
||||||
|
displays: deps.repo.listDisplays(),
|
||||||
|
error: errors.join(" "),
|
||||||
|
values: body,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = deps.repo.createLayout({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
template_id: templateId,
|
||||||
|
display_id: displayId,
|
||||||
|
priority,
|
||||||
|
is_default: isDefault,
|
||||||
|
resets_idle_timer: resetsIdleTimer,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layout.id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/layouts/:id", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const layout = deps.repo.getLayoutById(id);
|
||||||
|
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
|
const template = deps.repo.getLayoutTemplateById(layout.template_id);
|
||||||
|
if (!template) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
|
const display = deps.repo.getDisplayById(layout.display_id);
|
||||||
|
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
|
const cells = deps.repo.layoutCells(id);
|
||||||
|
const cameras = deps.repo.listCameras();
|
||||||
|
return htmlPage(LayoutEditPage({
|
||||||
|
user: user.username,
|
||||||
|
layout,
|
||||||
|
template,
|
||||||
|
display,
|
||||||
|
cells,
|
||||||
|
cameras,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/layouts/:id", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
|
||||||
|
const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10);
|
||||||
|
deps.repo.updateLayout(id, {
|
||||||
|
name: body?.["name"],
|
||||||
|
description: body?.["description"] || null,
|
||||||
|
priority: (body?.["priority"] ?? "normal") as any,
|
||||||
|
cooling_timeout_seconds: coolingTimeout,
|
||||||
|
is_default: body?.["is_default"] === "1",
|
||||||
|
resets_idle_timer: body?.["resets_idle_timer"] === "1",
|
||||||
|
});
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/layouts/:id/cells", async (event) => {
|
||||||
|
const layoutId = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const regionName = (body?.["region_name"] ?? "").trim();
|
||||||
|
const contentType = body?.["content_type"] ?? "camera";
|
||||||
|
|
||||||
|
if (!regionName) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.repo.createLayoutCell({
|
||||||
|
layout_id: layoutId,
|
||||||
|
region_name: regionName,
|
||||||
|
content_type: contentType,
|
||||||
|
camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null,
|
||||||
|
stream_selector: contentType === "camera" ? (body?.["stream_selector"] ?? "auto") : null,
|
||||||
|
web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null,
|
||||||
|
html_content: contentType === "html" ? (body?.["html_content"] ?? null) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/layouts/:id/cells/:cellId/delete", (event) => {
|
||||||
|
const layoutId = Number(getRouterParam(event, "id"));
|
||||||
|
const cellId = Number(getRouterParam(event, "cellId"));
|
||||||
|
deps.repo.deleteLayoutCell(cellId);
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/layouts/:id/delete", (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
deps.repo.deleteLayout(id);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Displays --------------------------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/displays", (event) => {
|
app.get("/admin/displays", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const displays = deps.repo.listDisplays();
|
const displays = deps.repo.listDisplays();
|
||||||
return htmlPage(SimpleListPage({
|
return htmlPage(DisplaysPage({ user: user.username, displays }));
|
||||||
user: user.username,
|
});
|
||||||
pageTitle: "Displays",
|
|
||||||
description: "Physical HDMI displays. Primary display created during setup.",
|
app.get("/admin/displays/:id", (event) => {
|
||||||
activeNav: "displays",
|
const user = event.context.user!;
|
||||||
items: displays.map((d) => ({
|
const id = Number(getRouterParam(event, "id"));
|
||||||
name: d.name,
|
const display = deps.repo.getDisplayById(id);
|
||||||
detail: `${d.width_px}x${d.height_px} — index ${d.index}`,
|
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
|
||||||
})),
|
const layouts = deps.repo.layoutsForDisplay(id);
|
||||||
}));
|
return htmlPage(DisplayEditPage({ user: user.username, display, layouts }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/displays/:id", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const defaultLayoutId = body?.["default_layout_id"] ? Number(body["default_layout_id"]) : null;
|
||||||
|
deps.repo.updateDisplay(id, {
|
||||||
|
name: body?.["name"],
|
||||||
|
default_layout_id: defaultLayoutId,
|
||||||
|
idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10),
|
||||||
|
sleep_timeout_seconds: parseInt(body?.["sleep_timeout_seconds"] ?? "0", 10),
|
||||||
|
width_px: body?.["width_px"] ? parseInt(body["width_px"], 10) : undefined,
|
||||||
|
height_px: body?.["height_px"] ? parseInt(body["height_px"], 10) : undefined,
|
||||||
|
} as any);
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/admin/labels", (event) => {
|
app.get("/admin/labels", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,30 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||||
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
||||||
deps.repo.markClusterKeyProvisioned();
|
deps.repo.markClusterKeyProvisioned();
|
||||||
|
|
||||||
deps.repo.createDefaultDisplay();
|
// Create default display, template, and layout
|
||||||
|
const display = deps.repo.createDefaultDisplay();
|
||||||
|
const template = deps.repo.createLayoutTemplate({
|
||||||
|
name: "Fullscreen",
|
||||||
|
description: "Single region covering the entire display",
|
||||||
|
regions: [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }],
|
||||||
|
grid_cols: 1,
|
||||||
|
grid_rows: 1,
|
||||||
|
is_builtin: true,
|
||||||
|
});
|
||||||
|
const layout = deps.repo.createLayout({
|
||||||
|
name: "Default",
|
||||||
|
description: "Default layout — BetterFrame logo",
|
||||||
|
template_id: template.id,
|
||||||
|
display_id: display.id,
|
||||||
|
is_default: true,
|
||||||
|
});
|
||||||
|
deps.repo.createLayoutCell({
|
||||||
|
layout_id: layout.id,
|
||||||
|
region_name: "main",
|
||||||
|
content_type: "html",
|
||||||
|
html_content: '<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#1a1a2e;color:#fff;font-family:system-ui"><h1 style="font-size:3rem;font-weight:300">BetterFrame</h1></div>',
|
||||||
|
});
|
||||||
|
deps.repo.updateDisplay(display.id, { default_layout_id: layout.id });
|
||||||
deps.repo.markSetupComplete();
|
deps.repo.markSetupComplete();
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,238 @@ export class Repository {
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateDisplay(id: number, patch: Partial<Display>): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id") continue;
|
||||||
|
const col = k === "index" ? `"index"` : k;
|
||||||
|
sets.push(`${col} = ?`);
|
||||||
|
vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE displays SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("displays", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// layout templates
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listLayoutTemplates(): LayoutTemplate[] {
|
||||||
|
const rs = this.prep("SELECT * FROM layout_templates ORDER BY name").all();
|
||||||
|
return rs.map((r) => rowToLayoutTemplate(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayoutTemplateById(id: number): LayoutTemplate | null {
|
||||||
|
const r = this.prep("SELECT * FROM layout_templates WHERE id = ?").get(id);
|
||||||
|
return r ? rowToLayoutTemplate(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLayoutTemplate(input: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
regions: unknown;
|
||||||
|
grid_cols?: number;
|
||||||
|
grid_rows?: number;
|
||||||
|
is_builtin?: boolean;
|
||||||
|
}): LayoutTemplate {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO layout_templates (name, description, regions, grid_cols, grid_rows, is_builtin)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.name,
|
||||||
|
input.description ?? null,
|
||||||
|
J(input.regions),
|
||||||
|
input.grid_cols ?? 12,
|
||||||
|
input.grid_rows ?? 12,
|
||||||
|
B(input.is_builtin ?? false),
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("layout_templates", "create", id);
|
||||||
|
const r = this.getLayoutTemplateById(id);
|
||||||
|
if (!r) throw new Error("layout_template vanished after insert");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayoutTemplate(id: number, patch: { name?: string; description?: string | null; regions?: unknown; grid_cols?: number; grid_rows?: number }): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
vals.push(k === "regions" ? J(v) : (v === undefined ? null : v));
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE layout_templates SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("layout_templates", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLayoutTemplate(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM layout_templates WHERE id = ?`).run(id);
|
||||||
|
void this.notify("layout_templates", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// layouts
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listLayouts(): Layout[] {
|
||||||
|
const rs = this.prep("SELECT * FROM layouts ORDER BY name").all();
|
||||||
|
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayoutById(id: number): Layout | null {
|
||||||
|
const r = this.prep("SELECT * FROM layouts WHERE id = ?").get(id);
|
||||||
|
return r ? rowToLayout(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutsForDisplay(displayId: number): Layout[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM layouts WHERE display_id = ? ORDER BY name",
|
||||||
|
).all(displayId);
|
||||||
|
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
createLayout(input: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
template_id: number;
|
||||||
|
display_id: number;
|
||||||
|
priority?: string;
|
||||||
|
cooling_timeout_seconds?: number | null;
|
||||||
|
preload_camera_ids?: number[];
|
||||||
|
is_default?: boolean;
|
||||||
|
resets_idle_timer?: boolean;
|
||||||
|
}): Layout {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO layouts (name, description, template_id, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.name,
|
||||||
|
input.description ?? null,
|
||||||
|
input.template_id,
|
||||||
|
input.display_id,
|
||||||
|
input.priority ?? "normal",
|
||||||
|
input.cooling_timeout_seconds ?? null,
|
||||||
|
J(input.preload_camera_ids ?? []),
|
||||||
|
B(input.is_default ?? false),
|
||||||
|
B(input.resets_idle_timer ?? true),
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("layouts", "create", id);
|
||||||
|
const r = this.getLayoutById(id);
|
||||||
|
if (!r) throw new Error("layout vanished after insert");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayout(id: number, patch: Partial<Layout>): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
if (k === "preload_camera_ids") vals.push(J(v));
|
||||||
|
else if (typeof v === "boolean") vals.push(B(v));
|
||||||
|
else vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE layouts SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("layouts", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLayout(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM layout_cells WHERE layout_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM layout_labels WHERE layout_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM layouts WHERE id = ?`).run(id);
|
||||||
|
void this.notify("layouts", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// layout cells
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
createLayoutCell(input: {
|
||||||
|
layout_id: number;
|
||||||
|
region_name: string;
|
||||||
|
content_type: string;
|
||||||
|
camera_id?: number | null;
|
||||||
|
stream_selector?: string | null;
|
||||||
|
web_url?: string | null;
|
||||||
|
html_content?: string | null;
|
||||||
|
cooling_timeout_seconds?: number | null;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}): LayoutCell {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO layout_cells (layout_id, region_name, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.layout_id,
|
||||||
|
input.region_name,
|
||||||
|
input.content_type,
|
||||||
|
input.camera_id ?? null,
|
||||||
|
input.stream_selector ?? null,
|
||||||
|
input.web_url ?? null,
|
||||||
|
input.html_content ?? null,
|
||||||
|
input.cooling_timeout_seconds ?? null,
|
||||||
|
J(input.options ?? {}),
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("layout_cells", "create", id);
|
||||||
|
const r = this.prep("SELECT * FROM layout_cells WHERE id = ?").get(id);
|
||||||
|
if (!r) throw new Error("layout_cell vanished after insert");
|
||||||
|
return rowToLayoutCell(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayoutCell(id: number, patch: Partial<LayoutCell>): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id" || k === "layout_id") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
if (k === "options") vals.push(J(v));
|
||||||
|
else vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE layout_cells SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("layout_cells", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLayoutCell(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM layout_cells WHERE id = ?`).run(id);
|
||||||
|
void this.notify("layout_cells", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// display-chain bundle queries (kiosk → display → layouts → cells → cameras)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
layoutsForDisplayId(displayId: number): Layout[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM layouts WHERE display_id = ? ORDER BY is_default DESC, name",
|
||||||
|
).all(displayId);
|
||||||
|
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
camerasForLayoutIds(layoutIds: number[]): Camera[] {
|
||||||
|
if (layoutIds.length === 0) return [];
|
||||||
|
const placeholders = layoutIds.map(() => "?").join(",");
|
||||||
|
const rs = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT c.* FROM cameras c
|
||||||
|
JOIN layout_cells lc ON lc.camera_id = c.id
|
||||||
|
WHERE lc.layout_id IN (${placeholders})
|
||||||
|
AND c.enabled = 1
|
||||||
|
ORDER BY c.name`,
|
||||||
|
)
|
||||||
|
.all(...(layoutIds as never[]));
|
||||||
|
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// cameras
|
// cameras
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -564,17 +796,19 @@ export class Repository {
|
||||||
key_prefix: string;
|
key_prefix: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
hardware_model?: string | null;
|
hardware_model?: string | null;
|
||||||
|
display_id?: number | null;
|
||||||
}): Kiosk {
|
}): Kiosk {
|
||||||
const result = this.prep(
|
const result = this.prep(
|
||||||
`INSERT INTO kiosks
|
`INSERT INTO kiosks
|
||||||
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at)
|
(name, key_hash, key_prefix, capabilities, hardware_model, display_id, paired_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.name,
|
input.name,
|
||||||
input.key_hash,
|
input.key_hash,
|
||||||
input.key_prefix,
|
input.key_prefix,
|
||||||
J(input.capabilities ?? []),
|
J(input.capabilities ?? []),
|
||||||
input.hardware_model ?? null,
|
input.hardware_model ?? null,
|
||||||
|
input.display_id ?? null,
|
||||||
isoNow(),
|
isoNow(),
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Label-scoped bundle generation — shared module.
|
* Bundle generation — display-chain routing.
|
||||||
*
|
*
|
||||||
* Queries cameras/layouts/templates for a kiosk's label set,
|
* kiosk.display_id → layouts for display → cells → cameras
|
||||||
* encrypts ONVIF passwords with cluster key, returns versioned bundle.
|
* No label filtering for v0.1.
|
||||||
*/
|
*/
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { Repository } from "../plugins/service-store/repository.js";
|
import type { Repository } from "../plugins/service-store/repository.js";
|
||||||
|
|
@ -17,6 +17,7 @@ export interface BundleCamera {
|
||||||
onvif_port: number | null;
|
onvif_port: number | null;
|
||||||
onvif_username: string | null;
|
onvif_username: string | null;
|
||||||
onvif_password_encrypted: string | null;
|
onvif_password_encrypted: string | null;
|
||||||
|
stream_policy: string;
|
||||||
streams: Array<{
|
streams: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
role: string;
|
role: string;
|
||||||
|
|
@ -29,40 +30,50 @@ export interface BundleCamera {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BundleCell {
|
||||||
|
region_name: string;
|
||||||
|
content_type: string;
|
||||||
|
camera_id: number | null;
|
||||||
|
stream_selector: string | null;
|
||||||
|
web_url: string | null;
|
||||||
|
html_content: string | null;
|
||||||
|
cooling_timeout_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BundleLayout {
|
export interface BundleLayout {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
template_id: number | null;
|
template: {
|
||||||
display_id: number | null;
|
|
||||||
priority: string;
|
|
||||||
cooling_timeout_seconds: number | null;
|
|
||||||
preload_camera_ids: number[];
|
|
||||||
is_default: boolean;
|
|
||||||
resets_idle_timer: boolean;
|
|
||||||
cells: Array<{
|
|
||||||
region_name: string;
|
|
||||||
content_type: string;
|
|
||||||
camera_id: number | null;
|
|
||||||
stream_selector: string | null;
|
|
||||||
web_url: string | null;
|
|
||||||
html_content: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KioskBundle {
|
|
||||||
kiosk_id: number;
|
|
||||||
kiosk_name: string;
|
|
||||||
labels: string[];
|
|
||||||
operate_labels: string[];
|
|
||||||
cameras: BundleCamera[];
|
|
||||||
layouts: BundleLayout[];
|
|
||||||
templates: Array<{
|
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
regions: unknown;
|
regions: unknown;
|
||||||
grid_cols: number;
|
grid_cols: number;
|
||||||
grid_rows: number;
|
grid_rows: number;
|
||||||
}>;
|
} | null;
|
||||||
|
priority: string;
|
||||||
|
cooling_timeout_seconds: number | null;
|
||||||
|
preload_camera_ids: number[];
|
||||||
|
is_default: boolean;
|
||||||
|
resets_idle_timer: boolean;
|
||||||
|
cells: BundleCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleDisplay {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
width_px: number;
|
||||||
|
height_px: number;
|
||||||
|
idle_timeout_seconds: number;
|
||||||
|
sleep_timeout_seconds: number;
|
||||||
|
default_layout_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KioskBundle {
|
||||||
|
kiosk_id: number;
|
||||||
|
kiosk_name: string;
|
||||||
|
display: BundleDisplay;
|
||||||
|
layouts: BundleLayout[];
|
||||||
|
cameras: BundleCamera[];
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,11 +84,46 @@ export function generateBundle(
|
||||||
clusterKey: string | undefined,
|
clusterKey: string | undefined,
|
||||||
): KioskBundle | null {
|
): KioskBundle | null {
|
||||||
const kiosk = repo.getKioskById(kioskId);
|
const kiosk = repo.getKioskById(kioskId);
|
||||||
if (!kiosk) return null;
|
if (!kiosk || !kiosk.display_id) return null;
|
||||||
|
|
||||||
const scope = repo.bundleScope(kioskId);
|
const display = repo.getDisplayById(kiosk.display_id);
|
||||||
const cameras = repo.camerasForLabelIds(scope.labelIds);
|
if (!display) return null;
|
||||||
const layouts = repo.layoutsForLabelIds(scope.labelIds);
|
|
||||||
|
const layouts = repo.layoutsForDisplayId(display.id);
|
||||||
|
const layoutIds = layouts.map((l) => l.id);
|
||||||
|
|
||||||
|
// Collect all cameras referenced by cells in these layouts
|
||||||
|
const cameras = repo.camerasForLayoutIds(layoutIds);
|
||||||
|
|
||||||
|
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
||||||
|
const cells = repo.layoutCells(l.id);
|
||||||
|
const template = l.template_id ? repo.getLayoutTemplateById(l.template_id) : null;
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
template: template ? {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
regions: template.regions,
|
||||||
|
grid_cols: template.grid_cols,
|
||||||
|
grid_rows: template.grid_rows,
|
||||||
|
} : null,
|
||||||
|
priority: l.priority,
|
||||||
|
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||||
|
preload_camera_ids: l.preload_camera_ids,
|
||||||
|
is_default: l.is_default,
|
||||||
|
resets_idle_timer: l.resets_idle_timer,
|
||||||
|
cells: cells.map((c) => ({
|
||||||
|
region_name: c.region_name,
|
||||||
|
content_type: c.content_type,
|
||||||
|
camera_id: c.camera_id,
|
||||||
|
stream_selector: c.stream_selector,
|
||||||
|
web_url: c.web_url,
|
||||||
|
html_content: c.html_content,
|
||||||
|
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
||||||
const streams = repo.listCameraStreams(cam.id);
|
const streams = repo.listCameraStreams(cam.id);
|
||||||
|
|
@ -94,6 +140,7 @@ export function generateBundle(
|
||||||
onvif_port: cam.onvif_port,
|
onvif_port: cam.onvif_port,
|
||||||
onvif_username: cam.onvif_username,
|
onvif_username: cam.onvif_username,
|
||||||
onvif_password_encrypted: onvifPwEncrypted,
|
onvif_password_encrypted: onvifPwEncrypted,
|
||||||
|
stream_policy: cam.stream_policy,
|
||||||
streams: streams.map((s) => ({
|
streams: streams.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
role: s.role,
|
role: s.role,
|
||||||
|
|
@ -107,46 +154,20 @@ export function generateBundle(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const templateIds = [...new Set(layouts.map((l) => l.template_id).filter((id): id is number => id !== null))];
|
|
||||||
const templates = templateIds.length > 0 ? repo.layoutTemplates(templateIds) : [];
|
|
||||||
|
|
||||||
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
|
||||||
const cells = repo.layoutCells(l.id);
|
|
||||||
return {
|
|
||||||
id: l.id,
|
|
||||||
name: l.name,
|
|
||||||
template_id: l.template_id,
|
|
||||||
display_id: l.display_id,
|
|
||||||
priority: l.priority,
|
|
||||||
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
|
||||||
preload_camera_ids: l.preload_camera_ids,
|
|
||||||
is_default: l.is_default,
|
|
||||||
resets_idle_timer: l.resets_idle_timer,
|
|
||||||
cells: cells.map((c) => ({
|
|
||||||
region_name: c.region_name,
|
|
||||||
content_type: c.content_type,
|
|
||||||
camera_id: c.camera_id,
|
|
||||||
stream_selector: c.stream_selector,
|
|
||||||
web_url: c.web_url,
|
|
||||||
html_content: c.html_content,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const bundle: KioskBundle = {
|
const bundle: KioskBundle = {
|
||||||
kiosk_id: kioskId,
|
kiosk_id: kioskId,
|
||||||
kiosk_name: kiosk.name,
|
kiosk_name: kiosk.name,
|
||||||
labels: scope.labelNames,
|
display: {
|
||||||
operate_labels: scope.operateLabelNames,
|
id: display.id,
|
||||||
cameras: bundleCameras,
|
name: display.name,
|
||||||
|
width_px: display.width_px,
|
||||||
|
height_px: display.height_px,
|
||||||
|
idle_timeout_seconds: display.idle_timeout_seconds,
|
||||||
|
sleep_timeout_seconds: display.sleep_timeout_seconds,
|
||||||
|
default_layout_id: display.default_layout_id,
|
||||||
|
},
|
||||||
layouts: bundleLayouts,
|
layouts: bundleLayouts,
|
||||||
templates: templates.map((t) => ({
|
cameras: bundleCameras,
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
regions: t.regions,
|
|
||||||
grid_cols: t.grid_cols,
|
|
||||||
grid_rows: t.grid_rows,
|
|
||||||
})),
|
|
||||||
version: "",
|
version: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,17 @@ export async function confirmPairing(
|
||||||
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
||||||
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
||||||
|
|
||||||
|
// Auto-assign to primary display
|
||||||
|
const displays = repo.listDisplays();
|
||||||
|
const primaryDisplay = displays.find((d) => d.is_primary) ?? displays[0];
|
||||||
|
|
||||||
const kiosk = repo.createKiosk({
|
const kiosk = repo.createKiosk({
|
||||||
name: kioskName,
|
name: kioskName,
|
||||||
key_hash: kioskKeyHash,
|
key_hash: kioskKeyHash,
|
||||||
key_prefix: kioskKeyPrefix,
|
key_prefix: kioskKeyPrefix,
|
||||||
capabilities: pc.kiosk_capabilities,
|
capabilities: pc.kiosk_capabilities,
|
||||||
hardware_model: pc.kiosk_hardware_model,
|
hardware_model: pc.kiosk_hardware_model,
|
||||||
|
display_id: primaryDisplay?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach initial labels
|
// Attach initial labels
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,18 @@
|
||||||
*/
|
*/
|
||||||
import { js } from "jsx-htmx";
|
import { js } from "jsx-htmx";
|
||||||
import { Layout } from "./layout.js";
|
import { Layout } from "./layout.js";
|
||||||
import type { Camera, Kiosk, Label, PairingCode, EventLog } from "../shared/types.js";
|
import type {
|
||||||
|
Camera,
|
||||||
|
Display,
|
||||||
|
Kiosk,
|
||||||
|
Label,
|
||||||
|
Layout as LayoutType,
|
||||||
|
LayoutCell,
|
||||||
|
LayoutRegion,
|
||||||
|
LayoutTemplate,
|
||||||
|
PairingCode,
|
||||||
|
EventLog,
|
||||||
|
} from "../shared/types.js";
|
||||||
|
|
||||||
// ---- Overview ---------------------------------------------------------------
|
// ---- Overview ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -800,6 +811,800 @@ export function LabelsPage(props: LabelsPageProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Templates (Layout Templates) -------------------------------------------
|
||||||
|
|
||||||
|
interface TemplatesPageProps {
|
||||||
|
user: string;
|
||||||
|
templates: LayoutTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesPage(props: TemplatesPageProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Layout Templates" user={props.user} activeNav="templates">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">All Templates</h2>
|
||||||
|
<a href="/admin/templates/new" class="btn btn-primary">New Template</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#666; margin-bottom:1.25rem">
|
||||||
|
Templates define named regions on a grid. Layouts bind content into these regions.
|
||||||
|
</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Grid</th>
|
||||||
|
<th>Regions</th>
|
||||||
|
<th>Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.templates.length === 0 ? (
|
||||||
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No templates created yet</td></tr>
|
||||||
|
) : (
|
||||||
|
props.templates.map((t) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/templates/${t.id}`}><strong>{t.name}</strong></a></td>
|
||||||
|
<td>{String(t.grid_cols)}x{String(t.grid_rows)}</td>
|
||||||
|
<td>{String(t.regions.length)} region{t.regions.length !== 1 ? "s" : ""}</td>
|
||||||
|
<td>
|
||||||
|
{t.is_builtin
|
||||||
|
? <span class="badge badge-gray">Built-in</span>
|
||||||
|
: <span class="badge badge-blue">Custom</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Template New -----------------------------------------------------------
|
||||||
|
|
||||||
|
interface TemplateNewPageProps {
|
||||||
|
user: string;
|
||||||
|
error?: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateNewPage(props: TemplateNewPageProps) {
|
||||||
|
const v = props.values ?? {};
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="New Template"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="templates"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<div style="max-width:700px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Choose a Preset</h2>
|
||||||
|
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
||||||
|
Pick a preset to get started quickly, or choose Custom to define your own regions.
|
||||||
|
</p>
|
||||||
|
<div class="stats-grid" style="margin-bottom:0">
|
||||||
|
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||||
|
<input type="hidden" name="preset" value="fullscreen" />
|
||||||
|
<input type="hidden" name="name" value="Fullscreen" />
|
||||||
|
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||||
|
<strong>Fullscreen</strong>
|
||||||
|
<div style="color:#666; font-size:0.8rem">1x1 grid, single region</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||||
|
<input type="hidden" name="preset" value="2x2" />
|
||||||
|
<input type="hidden" name="name" value="2x2 Grid" />
|
||||||
|
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||||
|
<strong>2x2 Grid</strong>
|
||||||
|
<div style="color:#666; font-size:0.8rem">4 equal regions</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||||
|
<input type="hidden" name="preset" value="1plus3" />
|
||||||
|
<input type="hidden" name="name" value="1+3" />
|
||||||
|
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||||
|
<strong>1+3</strong>
|
||||||
|
<div style="color:#666; font-size:0.8rem">Large left, 3 stacked right</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||||
|
<input type="hidden" name="preset" value="3x3" />
|
||||||
|
<input type="hidden" name="name" value="3x3 Grid" />
|
||||||
|
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||||
|
<strong>3x3 Grid</strong>
|
||||||
|
<div style="color:#666; font-size:0.8rem">9 equal regions</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Custom Template</h2>
|
||||||
|
<form method="post" action="/admin/templates/new">
|
||||||
|
<input type="hidden" name="preset" value="custom" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="grid_cols">Grid Columns</label>
|
||||||
|
<input id="grid_cols" name="grid_cols" type="number" class="form-input" min="1" max="12" value={v["grid_cols"] ?? "12"} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="grid_rows">Grid Rows</label>
|
||||||
|
<input id="grid_rows" name="grid_rows" type="number" class="form-input" min="1" max="12" value={v["grid_rows"] ?? "12"} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="regions">Regions (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
id="regions"
|
||||||
|
name="regions"
|
||||||
|
class="form-input"
|
||||||
|
rows="6"
|
||||||
|
placeholder={'[\n { "name": "main", "row": 0, "col": 0, "rowSpan": 12, "colSpan": 12 }\n]'}
|
||||||
|
>{v["regions"] ?? ""}</textarea>
|
||||||
|
<div class="form-hint">
|
||||||
|
Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Template</button>
|
||||||
|
<a href="/admin/templates" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Template Edit ----------------------------------------------------------
|
||||||
|
|
||||||
|
interface TemplateEditPageProps {
|
||||||
|
user: string;
|
||||||
|
template: LayoutTemplate;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateEditPage(props: TemplateEditPageProps) {
|
||||||
|
const t = props.template;
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Template: ${t.name}`}
|
||||||
|
user={props.user}
|
||||||
|
activeNav="templates"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="max-width:700px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Template</h2>
|
||||||
|
<form method="post" action={`/admin/templates/${t.id}`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" value={t.name} required maxlength="128" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<input id="description" name="description" type="text" class="form-input" value={t.description ?? ""} />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/templates" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid: {String(t.grid_cols)}x{String(t.grid_rows)}</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Region</th>
|
||||||
|
<th>Position</th>
|
||||||
|
<th>Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{t.regions.length === 0 ? (
|
||||||
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:1rem">No regions defined</td></tr>
|
||||||
|
) : (
|
||||||
|
t.regions.map((r) => (
|
||||||
|
<tr>
|
||||||
|
<td><strong>{r.name}</strong></td>
|
||||||
|
<td>row {String(r.row)}, col {String(r.col)}</td>
|
||||||
|
<td>{String(r.rowSpan)}x{String(r.colSpan)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual grid preview */}
|
||||||
|
{t.regions.length > 0 && (
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Preview</h2>
|
||||||
|
<div style={`display:grid; grid-template-columns:repeat(${String(t.grid_cols)}, 1fr); grid-template-rows:repeat(${String(t.grid_rows)}, 30px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
|
||||||
|
{t.regions.map((r) => (
|
||||||
|
<div style={`grid-column:${String(r.col + 1)} / span ${String(r.colSpan)}; grid-row:${String(r.row + 1)} / span ${String(r.rowSpan)}; background:#dbeafe; display:flex; align-items:center; justify-content:center; font-size:0.75rem; font-weight:600; color:#1e40af; border-radius:2px`}>
|
||||||
|
{r.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!t.is_builtin && (
|
||||||
|
<form method="post" action={`/admin/templates/${t.id}/delete`} style="margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this template? Layouts using it will break.')"}}>Delete Template</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layouts ----------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LayoutsPageProps {
|
||||||
|
user: string;
|
||||||
|
layouts: LayoutType[];
|
||||||
|
templates: Map<number, LayoutTemplate>;
|
||||||
|
displays: Map<number, Display>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutsPage(props: LayoutsPageProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Layouts" user={props.user} activeNav="layouts">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">All Layouts</h2>
|
||||||
|
<a href="/admin/layouts/new" class="btn btn-primary">New Layout</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#666; margin-bottom:1.25rem">
|
||||||
|
A layout binds cameras and other content into a template's regions for one display.
|
||||||
|
</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Display</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Default</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.layouts.length === 0 ? (
|
||||||
|
<tr><td colspan="5" style="text-align:center; color:#999; padding:2rem">No layouts created yet</td></tr>
|
||||||
|
) : (
|
||||||
|
props.layouts.map((l) => {
|
||||||
|
const tmpl = props.templates.get(l.template_id);
|
||||||
|
const disp = props.displays.get(l.display_id);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
||||||
|
<td>{tmpl ? tmpl.name : `#${String(l.template_id)}`}</td>
|
||||||
|
<td>{disp ? disp.name : `#${String(l.display_id)}`}</td>
|
||||||
|
<td>
|
||||||
|
{l.priority === "hot"
|
||||||
|
? <span class="badge badge-red">hot</span>
|
||||||
|
: l.priority === "cold"
|
||||||
|
? <span class="badge badge-blue">cold</span>
|
||||||
|
: <span class="badge badge-gray">normal</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{l.is_default ? <span class="badge badge-green">Yes</span> : ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layout New -------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LayoutNewPageProps {
|
||||||
|
user: string;
|
||||||
|
templates: LayoutTemplate[];
|
||||||
|
displays: Display[];
|
||||||
|
error?: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutNewPage(props: LayoutNewPageProps) {
|
||||||
|
const v = props.values ?? {};
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="New Layout"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="layouts"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<form method="post" action="/admin/layouts/new">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Layout Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_id">Template</label>
|
||||||
|
<select id="template_id" name="template_id" class="form-input" required>
|
||||||
|
<option value="">-- Select Template --</option>
|
||||||
|
{props.templates.map((t) => (
|
||||||
|
<option value={String(t.id)} selected={v["template_id"] === String(t.id)}>
|
||||||
|
{t.name} ({String(t.grid_cols)}x{String(t.grid_rows)}, {String(t.regions.length)} regions)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{props.templates.length === 0 && (
|
||||||
|
<div class="form-hint">
|
||||||
|
No templates exist. <a href="/admin/templates/new">Create one first</a>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_id">Display</label>
|
||||||
|
<select id="display_id" name="display_id" class="form-input" required>
|
||||||
|
<option value="">-- Select Display --</option>
|
||||||
|
{props.displays.map((d) => (
|
||||||
|
<option value={String(d.id)} selected={v["display_id"] === String(d.id)}>
|
||||||
|
{d.name} ({String(d.width_px)}x{String(d.height_px)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="priority">Priority</label>
|
||||||
|
<select id="priority" name="priority" class="form-input">
|
||||||
|
<option value="normal" selected={v["priority"] !== "hot" && v["priority"] !== "cold"}>Normal</option>
|
||||||
|
<option value="hot" selected={v["priority"] === "hot"}>Hot (always warm)</option>
|
||||||
|
<option value="cold" selected={v["priority"] === "cold"}>Cold</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description (optional)</label>
|
||||||
|
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_default" value="1" checked={v["is_default"] === "1"} />
|
||||||
|
{" "}Set as default layout for this display
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="resets_idle_timer" value="1" checked={v["resets_idle_timer"] !== "0"} />
|
||||||
|
{" "}Resets idle timer when activated
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Create Layout</button>
|
||||||
|
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layout Edit ------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LayoutEditPageProps {
|
||||||
|
user: string;
|
||||||
|
layout: LayoutType;
|
||||||
|
template: LayoutTemplate;
|
||||||
|
display: Display;
|
||||||
|
cells: LayoutCell[];
|
||||||
|
cameras: Camera[];
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
|
const l = props.layout;
|
||||||
|
const t = props.template;
|
||||||
|
// Build a map from region_name → cell for easy lookup
|
||||||
|
const cellByRegion = new Map<string, LayoutCell>();
|
||||||
|
for (const c of props.cells) {
|
||||||
|
cellByRegion.set(c.region_name, c);
|
||||||
|
}
|
||||||
|
// Also build camera name lookup
|
||||||
|
const cameraById = new Map<number, Camera>();
|
||||||
|
for (const cam of props.cameras) {
|
||||||
|
cameraById.set(cam.id, cam);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Layout: ${l.name}`}
|
||||||
|
user={props.user}
|
||||||
|
activeNav="layouts"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="max-width:800px">
|
||||||
|
{/* Settings */}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Settings</h2>
|
||||||
|
<form method="post" action={`/admin/layouts/${l.id}`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" value={l.name} required maxlength="128" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<input id="description" name="description" type="text" class="form-input" value={l.description ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="priority">Priority</label>
|
||||||
|
<select id="priority" name="priority" class="form-input">
|
||||||
|
<option value="normal" selected={l.priority === "normal"}>Normal</option>
|
||||||
|
<option value="hot" selected={l.priority === "hot"}>Hot</option>
|
||||||
|
<option value="cold" selected={l.priority === "cold"}>Cold</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cooling_timeout_seconds">Cooling Timeout (seconds)</label>
|
||||||
|
<input id="cooling_timeout_seconds" name="cooling_timeout_seconds" type="number" class="form-input" value={l.cooling_timeout_seconds != null ? String(l.cooling_timeout_seconds) : ""} min="0" placeholder="None" />
|
||||||
|
<div class="form-hint">How long streams stay warm after leaving this layout. Leave blank for no timeout.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_default" value="1" checked={l.is_default} />
|
||||||
|
{" "}Default layout for display
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="resets_idle_timer" value="1" checked={l.resets_idle_timer} />
|
||||||
|
{" "}Resets idle timer
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
|
||||||
|
<div>Template: <a href={`/admin/templates/${t.id}`}>{t.name}</a> ({String(t.grid_cols)}x{String(t.grid_rows)})</div>
|
||||||
|
<div>Display: <a href={`/admin/displays/${props.display.id}`}>{props.display.name}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template preview with cell assignments */}
|
||||||
|
{t.regions.length > 0 && (
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid Preview</h2>
|
||||||
|
<div style={`display:grid; grid-template-columns:repeat(${String(t.grid_cols)}, 1fr); grid-template-rows:repeat(${String(t.grid_rows)}, 40px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
|
||||||
|
{t.regions.map((r) => {
|
||||||
|
const cell = cellByRegion.get(r.name);
|
||||||
|
let label = r.name;
|
||||||
|
let bgColor = "#f9fafb";
|
||||||
|
let textColor = "#666";
|
||||||
|
if (cell) {
|
||||||
|
bgColor = "#dbeafe";
|
||||||
|
textColor = "#1e40af";
|
||||||
|
if (cell.content_type === "camera" && cell.camera_id) {
|
||||||
|
const cam = cameraById.get(cell.camera_id);
|
||||||
|
label = cam ? cam.name : `cam #${String(cell.camera_id)}`;
|
||||||
|
} else if (cell.content_type === "web") {
|
||||||
|
label = "Web";
|
||||||
|
} else if (cell.content_type === "html") {
|
||||||
|
label = "HTML";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={`grid-column:${String(r.col + 1)} / span ${String(r.colSpan)}; grid-row:${String(r.row + 1)} / span ${String(r.rowSpan)}; background:${bgColor}; display:flex; align-items:center; justify-content:center; font-size:0.7rem; font-weight:600; color:${textColor}; border-radius:2px; overflow:hidden; text-overflow:ellipsis; padding:0 4px`}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cell assignments table */}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Cell Assignments</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Region</th>
|
||||||
|
<th>Content</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{t.regions.map((r) => {
|
||||||
|
const cell = cellByRegion.get(r.name);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td><strong>{r.name}</strong></td>
|
||||||
|
<td>
|
||||||
|
{cell ? (
|
||||||
|
<span>
|
||||||
|
<span class="badge badge-blue">{cell.content_type}</span>
|
||||||
|
{" "}
|
||||||
|
{cell.content_type === "camera" && cell.camera_id
|
||||||
|
? (cameraById.get(cell.camera_id)?.name ?? `#${String(cell.camera_id)}`)
|
||||||
|
: cell.content_type === "web" && cell.web_url
|
||||||
|
? <span style="font-size:0.8rem; color:#666">{cell.web_url}</span>
|
||||||
|
: cell.content_type === "html"
|
||||||
|
? <span style="font-size:0.8rem; color:#666">(custom HTML)</span>
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style="color:#999">Empty</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{cell && (
|
||||||
|
<form method="post" action={`/admin/layouts/${l.id}/cells/${cell.id}/delete`} style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Remove this cell?')"}}>Remove</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add cell form */}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Assign Content to Region</h2>
|
||||||
|
<form method="post" action={`/admin/layouts/${l.id}/cells`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="region_name">Region</label>
|
||||||
|
<select id="region_name" name="region_name" class="form-input" required>
|
||||||
|
<option value="">-- Select Region --</option>
|
||||||
|
{t.regions.map((r) => {
|
||||||
|
const taken = cellByRegion.has(r.name);
|
||||||
|
return (
|
||||||
|
<option value={r.name} disabled={taken}>
|
||||||
|
{r.name}{taken ? " (assigned)" : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content_type">Content Type</label>
|
||||||
|
<select id="content_type" name="content_type" class="form-input" required>
|
||||||
|
<option value="camera">Camera</option>
|
||||||
|
<option value="web">Web URL</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="camera-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="camera_id">Camera</label>
|
||||||
|
<select id="camera_id" name="camera_id" class="form-input">
|
||||||
|
<option value="">-- Select Camera --</option>
|
||||||
|
{props.cameras.map((cam) => (
|
||||||
|
<option value={String(cam.id)}>{cam.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="stream_selector">Stream</label>
|
||||||
|
<select id="stream_selector" name="stream_selector" class="form-input">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="main">Main</option>
|
||||||
|
<option value="sub">Sub</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="web-fields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="web_url">URL</label>
|
||||||
|
<input id="web_url" name="web_url" type="url" class="form-input" placeholder="https://example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="html-fields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="html_content">HTML Content</label>
|
||||||
|
<textarea id="html_content" name="html_content" class="form-input" rows="4" placeholder="<div>...</div>"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Assign</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>{js(
|
||||||
|
`(function(){` +
|
||||||
|
`var sel=document.getElementById("content_type");` +
|
||||||
|
`var cf=document.getElementById("camera-fields");` +
|
||||||
|
`var wf=document.getElementById("web-fields");` +
|
||||||
|
`var hf=document.getElementById("html-fields");` +
|
||||||
|
`function t(){var v=sel?sel.value:"camera";` +
|
||||||
|
`if(cf)cf.style.display=v==="camera"?"block":"none";` +
|
||||||
|
`if(wf)wf.style.display=v==="web"?"block":"none";` +
|
||||||
|
`if(hf)hf.style.display=v==="html"?"block":"none";}` +
|
||||||
|
`if(sel)sel.addEventListener("change",t);t();})()`
|
||||||
|
)}</script>
|
||||||
|
|
||||||
|
<form method="post" action={`/admin/layouts/${l.id}/delete`} style="margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this layout and all its cells?')"}}>Delete Layout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Display Edit -----------------------------------------------------------
|
||||||
|
|
||||||
|
interface DisplayEditPageProps {
|
||||||
|
user: string;
|
||||||
|
display: Display;
|
||||||
|
layouts: LayoutType[];
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DisplayEditPage(props: DisplayEditPageProps) {
|
||||||
|
const d = props.display;
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Display: ${d.name}`}
|
||||||
|
user={props.user}
|
||||||
|
activeNav="displays"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Display Info</h2>
|
||||||
|
<div style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
||||||
|
<div>Index: {String(d.index)}</div>
|
||||||
|
<div>Resolution: {String(d.width_px)}x{String(d.height_px)}</div>
|
||||||
|
<div>Primary: {d.is_primary ? "Yes" : "No"}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action={`/admin/displays/${d.id}`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" value={d.name} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="default_layout_id">Default Layout</label>
|
||||||
|
<select id="default_layout_id" name="default_layout_id" class="form-input">
|
||||||
|
<option value="">-- None --</option>
|
||||||
|
{props.layouts.map((l) => (
|
||||||
|
<option value={String(l.id)} selected={d.default_layout_id === l.id}>
|
||||||
|
{l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">Layout shown on idle revert.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="idle_timeout_seconds">Idle Timeout (seconds)</label>
|
||||||
|
<input id="idle_timeout_seconds" name="idle_timeout_seconds" type="number" class="form-input" value={String(d.idle_timeout_seconds)} min="0" />
|
||||||
|
<div class="form-hint">Revert to default layout after this many seconds of inactivity. 0 to disable.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sleep_timeout_seconds">Sleep Timeout (seconds)</label>
|
||||||
|
<input id="sleep_timeout_seconds" name="sleep_timeout_seconds" type="number" class="form-input" value={String(d.sleep_timeout_seconds)} min="0" />
|
||||||
|
<div class="form-hint">Send CEC standby after this many seconds of inactivity. 0 to disable.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="width_px">Width (px)</label>
|
||||||
|
<input id="width_px" name="width_px" type="number" class="form-input" value={String(d.width_px)} min="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="height_px">Height (px)</label>
|
||||||
|
<input id="height_px" name="height_px" type="number" class="form-input" value={String(d.height_px)} min="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/displays" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.layouts.length > 0 && (
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layouts on This Display</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Default</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.layouts.map((l) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
||||||
|
<td><span class={`badge ${l.priority === "hot" ? "badge-red" : l.priority === "cold" ? "badge-blue" : "badge-gray"}`}>{l.priority}</span></td>
|
||||||
|
<td>{l.is_default ? <span class="badge badge-green">Yes</span> : ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Displays List (with clickable links) -----------------------------------
|
||||||
|
|
||||||
|
interface DisplaysPageProps {
|
||||||
|
user: string;
|
||||||
|
displays: Display[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DisplaysPage(props: DisplaysPageProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Displays" user={props.user} activeNav="displays">
|
||||||
|
<p style="color:#666; margin-bottom:1.25rem">Physical HDMI displays. Primary display created during setup.</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.displays.length === 0 ? (
|
||||||
|
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
|
||||||
|
) : (
|
||||||
|
props.displays.map((d) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td>
|
||||||
|
<td style="color:#666">{String(d.width_px)}x{String(d.height_px)} — index {String(d.index)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----------------------------------------------------------------
|
// ---- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue