mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
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:
parent
7fbda3c2b3
commit
533412a826
11 changed files with 3485 additions and 511 deletions
2821
kiosk/Cargo.lock
generated
Normal file
2821
kiosk/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -25,7 +25,6 @@ pub struct BundleDisplay {
|
||||||
pub struct BundleLayout {
|
pub struct BundleLayout {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub regions: Vec<BundleRegion>,
|
|
||||||
pub grid_cols: u32,
|
pub grid_cols: u32,
|
||||||
pub grid_rows: u32,
|
pub grid_rows: u32,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
|
|
@ -37,19 +36,11 @@ pub struct BundleLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleRegion {
|
pub struct BundleCell {
|
||||||
pub name: String,
|
|
||||||
pub row: u32,
|
pub row: u32,
|
||||||
pub col: u32,
|
pub col: u32,
|
||||||
#[serde(rename = "rowSpan")]
|
|
||||||
pub row_span: u32,
|
pub row_span: u32,
|
||||||
#[serde(rename = "colSpan")]
|
|
||||||
pub col_span: u32,
|
pub col_span: u32,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct BundleCell {
|
|
||||||
pub region_name: String,
|
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
pub camera_id: Option<u32>,
|
pub camera_id: Option<u32>,
|
||||||
pub stream_selector: Option<String>,
|
pub stream_selector: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if layout.regions.is_empty() {
|
if layout.cells.is_empty() {
|
||||||
warn!("layout has no regions");
|
warn!("layout has no cells");
|
||||||
show_logo(window);
|
show_logo(window);
|
||||||
return;
|
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()));
|
let pipelines: Rc<RefCell<Vec<gstreamer::Pipeline>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|
||||||
for cell in &layout.cells {
|
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() {
|
let widget: gtk::Widget = match cell.content_type.as_str() {
|
||||||
"camera" => {
|
"camera" => {
|
||||||
if let Some(cam_id) = cell.camera_id {
|
if let Some(cam_id) = cell.camera_id {
|
||||||
|
|
@ -192,30 +186,13 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
||||||
|
|
||||||
grid.attach(
|
grid.attach(
|
||||||
&widget,
|
&widget,
|
||||||
region.col as i32,
|
cell.col as i32,
|
||||||
region.row as i32,
|
cell.row as i32,
|
||||||
region.col_span as i32,
|
cell.col_span as i32,
|
||||||
region.row_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));
|
window.set_child(Some(&grid));
|
||||||
|
|
||||||
let pipelines_ref = pipelines.clone();
|
let pipelines_ref = pipelines.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Admin page routes — overview, cameras, kiosks, labels, etc.
|
* 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 { htmlPage } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
import { confirmPairing } from "../../shared/pairing.js";
|
import { confirmPairing } from "../../shared/pairing.js";
|
||||||
|
|
@ -19,7 +19,6 @@ import {
|
||||||
DisplaysPage,
|
DisplaysPage,
|
||||||
DisplayEditPage,
|
DisplayEditPage,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
import type { Display } from "../../shared/types.js";
|
|
||||||
|
|
||||||
function sanitizeRtspUrl(raw: string): string {
|
function sanitizeRtspUrl(raw: string): string {
|
||||||
const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/);
|
const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/);
|
||||||
|
|
@ -198,105 +197,36 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
app.get("/admin/layouts", (event) => {
|
app.get("/admin/layouts", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const layouts = deps.repo.listLayouts();
|
const layouts = deps.repo.listLayouts();
|
||||||
const displayIds = [...new Set(layouts.map((l) => l.display_id))];
|
// For each layout, how many displays use it (for the list view).
|
||||||
const displays = new Map<number, Display>();
|
const displayCounts = new Map<number, number>();
|
||||||
for (const did of displayIds) {
|
for (const l of layouts) {
|
||||||
const d = deps.repo.getDisplayById(did);
|
displayCounts.set(l.id, deps.repo.listDisplaysForLayout(l.id).length);
|
||||||
if (d) displays.set(did, d);
|
|
||||||
}
|
}
|
||||||
return htmlPage(LayoutsPage({ user: user.username, layouts, displays }));
|
return htmlPage(LayoutsPage({ user: user.username, layouts, displayCounts }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/admin/layouts/new", (event) => {
|
app.get("/admin/layouts/new", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
return htmlPage(LayoutNewPage({
|
return htmlPage(LayoutNewPage({ user: user.username }));
|
||||||
user: user.username,
|
|
||||||
displays: deps.repo.listDisplays(),
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/layouts/new", async (event) => {
|
app.post("/admin/layouts/new", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const name = (body?.["name"] ?? "").trim();
|
const name = (body?.["name"] ?? "").trim();
|
||||||
const preset = body?.["preset"] ?? "custom";
|
|
||||||
const displayId = parseInt(body?.["display_id"] ?? "", 10);
|
|
||||||
const priority = body?.["priority"] ?? "normal";
|
const priority = body?.["priority"] ?? "normal";
|
||||||
const description = (body?.["description"] ?? "").trim() || null;
|
const description = (body?.["description"] ?? "").trim() || null;
|
||||||
const isDefault = body?.["is_default"] === "1";
|
|
||||||
const resetsIdleTimer = body?.["resets_idle_timer"] === "1";
|
const resetsIdleTimer = body?.["resets_idle_timer"] === "1";
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
|
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
|
||||||
if (isNaN(displayId)) errors.push("Select a display.");
|
if (priority !== "hot" && priority !== "normal" && priority !== "cold") {
|
||||||
|
errors.push("Priority must be hot/normal/cold.");
|
||||||
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 (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return htmlPage(LayoutNewPage({
|
return htmlPage(LayoutNewPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
displays: deps.repo.listDisplays(),
|
|
||||||
error: errors.join(" "),
|
error: errors.join(" "),
|
||||||
values: body,
|
values: body,
|
||||||
}));
|
}));
|
||||||
|
|
@ -305,12 +235,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const layout = deps.repo.createLayout({
|
const layout = deps.repo.createLayout({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
regions,
|
|
||||||
grid_cols: gridCols,
|
|
||||||
grid_rows: gridRows,
|
|
||||||
display_id: displayId,
|
|
||||||
priority,
|
priority,
|
||||||
is_default: isDefault,
|
|
||||||
resets_idle_timer: resetsIdleTimer,
|
resets_idle_timer: resetsIdleTimer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -322,16 +247,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
const layout = deps.repo.getLayoutById(id);
|
const layout = deps.repo.getLayoutById(id);
|
||||||
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
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 cells = deps.repo.layoutCells(id);
|
||||||
const cameras = deps.repo.listCameras();
|
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({
|
return htmlPage(LayoutEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
layout,
|
layout,
|
||||||
display,
|
displays,
|
||||||
cells,
|
cells,
|
||||||
cameras,
|
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,
|
description: body?.["description"] || null,
|
||||||
priority: (body?.["priority"] ?? "normal") as any,
|
priority: (body?.["priority"] ?? "normal") as any,
|
||||||
cooling_timeout_seconds: coolingTimeout,
|
cooling_timeout_seconds: coolingTimeout,
|
||||||
is_default: body?.["is_default"] === "1",
|
|
||||||
resets_idle_timer: body?.["resets_idle_timer"] === "1",
|
resets_idle_timer: body?.["resets_idle_timer"] === "1",
|
||||||
});
|
});
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${id}` } });
|
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) => {
|
app.post("/admin/layouts/:id/cells", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = Number(getRouterParam(event, "id"));
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string | number | { row: number; col: number }>>(event);
|
||||||
const regionName = (body?.["region_name"] ?? "").trim();
|
|
||||||
const contentType = body?.["content_type"] ?? "camera";
|
|
||||||
|
|
||||||
if (!regionName) {
|
let row = 0;
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
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({
|
deps.repo.createLayoutCell({
|
||||||
layout_id: layoutId,
|
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,
|
content_type: contentType,
|
||||||
camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null,
|
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,
|
web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null,
|
||||||
html_content: contentType === "html" ? (body?.["html_content"] ?? 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 id = Number(getRouterParam(event, "id"));
|
||||||
const display = deps.repo.getDisplayById(id);
|
const display = deps.repo.getDisplayById(id);
|
||||||
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
|
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;
|
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) => {
|
app.post("/admin/displays/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
const body = await readBody<Record<string, string>>(event);
|
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, {
|
deps.repo.updateDisplay(id, {
|
||||||
name: body?.["name"],
|
name: body?.["name"],
|
||||||
default_layout_id: defaultLayoutId,
|
default_layout_id: validatedDefault,
|
||||||
idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10),
|
idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10),
|
||||||
sleep_timeout_seconds: parseInt(body?.["sleep_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);
|
} as any);
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } });
|
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) => {
|
app.get("/admin/labels", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
return htmlPage(LabelsPage({ user: user.username, labels: deps.repo.listLabels() }));
|
return htmlPage(LabelsPage({ user: user.username, labels: deps.repo.listLabels() }));
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export function rowToLayout(r: Row): Layout {
|
||||||
regions: j<LayoutRegion[]>(r["regions"], []),
|
regions: j<LayoutRegion[]>(r["regions"], []),
|
||||||
grid_cols: n(r["grid_cols"]) || 1,
|
grid_cols: n(r["grid_cols"]) || 1,
|
||||||
grid_rows: n(r["grid_rows"]) || 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,
|
priority: s(r["priority"]) as LayoutPriority,
|
||||||
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []),
|
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []),
|
||||||
|
|
@ -194,6 +194,10 @@ export function rowToLayoutCell(r: Row): LayoutCell {
|
||||||
id: n(r["id"]),
|
id: n(r["id"]),
|
||||||
layout_id: n(r["layout_id"]),
|
layout_id: n(r["layout_id"]),
|
||||||
region_name: s(r["region_name"]),
|
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,
|
content_type: s(r["content_type"]) as CellContentType,
|
||||||
camera_id: nn(r["camera_id"]),
|
camera_id: nn(r["camera_id"]),
|
||||||
stream_selector: s(r["stream_selector"]) as StreamSelector,
|
stream_selector: s(r["stream_selector"]) as StreamSelector,
|
||||||
|
|
|
||||||
|
|
@ -284,4 +284,86 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL");
|
addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL");
|
||||||
},
|
},
|
||||||
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
|
`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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -484,42 +484,87 @@ export class Repository {
|
||||||
return r ? rowToLayout(r as Record<string, unknown>) : null;
|
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[] {
|
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(
|
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);
|
).all(displayId);
|
||||||
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
|
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: {
|
createLayout(input: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
template_id?: number | null;
|
|
||||||
regions: unknown;
|
|
||||||
grid_cols: number;
|
|
||||||
grid_rows: number;
|
|
||||||
display_id: number;
|
|
||||||
priority?: string;
|
priority?: string;
|
||||||
cooling_timeout_seconds?: number | null;
|
cooling_timeout_seconds?: number | null;
|
||||||
preload_camera_ids?: number[];
|
preload_camera_ids?: number[];
|
||||||
is_default?: boolean;
|
|
||||||
resets_idle_timer?: boolean;
|
resets_idle_timer?: boolean;
|
||||||
}): Layout {
|
}): 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(
|
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)
|
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.name,
|
input.name,
|
||||||
input.description ?? null,
|
input.description ?? null,
|
||||||
input.template_id ?? null,
|
null,
|
||||||
J(input.regions),
|
J([]),
|
||||||
input.grid_cols,
|
1,
|
||||||
input.grid_rows,
|
1,
|
||||||
input.display_id,
|
0,
|
||||||
input.priority ?? "normal",
|
input.priority ?? "normal",
|
||||||
input.cooling_timeout_seconds ?? null,
|
input.cooling_timeout_seconds ?? null,
|
||||||
J(input.preload_camera_ids ?? []),
|
J(input.preload_camera_ids ?? []),
|
||||||
B(input.is_default ?? false),
|
B(false),
|
||||||
B(input.resets_idle_timer ?? true),
|
B(input.resets_idle_timer ?? true),
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
|
|
@ -533,7 +578,7 @@ export class Repository {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const vals: unknown[] = [];
|
const vals: unknown[] = [];
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
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} = ?`);
|
sets.push(`${k} = ?`);
|
||||||
if (k === "preload_camera_ids" || k === "regions") vals.push(J(v));
|
if (k === "preload_camera_ids" || k === "regions") vals.push(J(v));
|
||||||
else if (typeof v === "boolean") vals.push(B(v));
|
else if (typeof v === "boolean") vals.push(B(v));
|
||||||
|
|
@ -548,6 +593,9 @@ export class Repository {
|
||||||
deleteLayout(id: number): void {
|
deleteLayout(id: number): void {
|
||||||
this.db.prepare(`DELETE FROM layout_cells WHERE layout_id = ?`).run(id);
|
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 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);
|
this.db.prepare(`DELETE FROM layouts WHERE id = ?`).run(id);
|
||||||
void this.notify("layouts", "delete", id);
|
void this.notify("layouts", "delete", id);
|
||||||
}
|
}
|
||||||
|
|
@ -558,7 +606,10 @@ export class Repository {
|
||||||
|
|
||||||
createLayoutCell(input: {
|
createLayoutCell(input: {
|
||||||
layout_id: number;
|
layout_id: number;
|
||||||
region_name: string;
|
row: number;
|
||||||
|
col: number;
|
||||||
|
row_span?: number;
|
||||||
|
col_span?: number;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
camera_id?: number | null;
|
camera_id?: number | null;
|
||||||
stream_selector?: string | null;
|
stream_selector?: string | null;
|
||||||
|
|
@ -567,12 +618,19 @@ export class Repository {
|
||||||
cooling_timeout_seconds?: number | null;
|
cooling_timeout_seconds?: number | null;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
}): LayoutCell {
|
}): 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(
|
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)
|
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.layout_id,
|
input.layout_id,
|
||||||
input.region_name,
|
placeholder,
|
||||||
|
input.row,
|
||||||
|
input.col,
|
||||||
|
input.row_span ?? 1,
|
||||||
|
input.col_span ?? 1,
|
||||||
input.content_type,
|
input.content_type,
|
||||||
input.camera_id ?? null,
|
input.camera_id ?? null,
|
||||||
input.stream_selector ?? "auto",
|
input.stream_selector ?? "auto",
|
||||||
|
|
@ -593,7 +651,8 @@ export class Repository {
|
||||||
const vals: unknown[] = [];
|
const vals: unknown[] = [];
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
if (k === "id" || k === "layout_id") continue;
|
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));
|
if (k === "options") vals.push(J(v));
|
||||||
else vals.push(v === undefined ? null : v);
|
else vals.push(v === undefined ? null : v);
|
||||||
}
|
}
|
||||||
|
|
@ -608,15 +667,45 @@ export class Repository {
|
||||||
void this.notify("layout_cells", "delete", id);
|
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)
|
// display-chain bundle queries (kiosk → display → layouts → cells → cameras)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
|
/** Bundle generation: layouts attached to a display via display_layouts. */
|
||||||
layoutsForDisplayId(displayId: number): Layout[] {
|
layoutsForDisplayId(displayId: number): Layout[] {
|
||||||
const rs = this.prep(
|
return this.listLayoutsForDisplay(displayId);
|
||||||
"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[] {
|
camerasForLayoutIds(layoutIds: number[]): Camera[] {
|
||||||
|
|
@ -1064,10 +1153,7 @@ export class Repository {
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutCells(layoutId: number): LayoutCell[] {
|
layoutCells(layoutId: number): LayoutCell[] {
|
||||||
const rs = this.prep(
|
return this.listLayoutCells(layoutId);
|
||||||
"SELECT * FROM layout_cells WHERE layout_id = ?",
|
|
||||||
).all(layoutId);
|
|
||||||
return rs.map((r) => rowToLayoutCell(r as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutTemplates(ids: number[]): LayoutTemplate[] {
|
layoutTemplates(ids: number[]): LayoutTemplate[] {
|
||||||
|
|
|
||||||
|
|
@ -58,27 +58,18 @@ const camera = av.object(
|
||||||
{ unknownKeys: "reject" },
|
{ 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(
|
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,
|
content_type: cellContentType,
|
||||||
camera_id: av.nullable(av.int().min(1)),
|
camera_id: av.nullable(av.int().min(1)),
|
||||||
stream_selector: streamSelector,
|
stream_selector: streamSelector,
|
||||||
web_url: av.nullable(av.string()),
|
web_url: av.nullable(av.string()),
|
||||||
html_content: av.nullable(av.string()),
|
html_content: av.nullable(av.string()),
|
||||||
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
||||||
options: av.record(av.unknown()),
|
|
||||||
},
|
},
|
||||||
{ unknownKeys: "reject" },
|
{ unknownKeys: "reject" },
|
||||||
);
|
);
|
||||||
|
|
@ -87,14 +78,13 @@ const layout = av.object(
|
||||||
{
|
{
|
||||||
id: av.int().min(1),
|
id: av.int().min(1),
|
||||||
name: av.string().minLength(1).maxLength(128),
|
name: av.string().minLength(1).maxLength(128),
|
||||||
regions: av.array(layoutRegion),
|
|
||||||
grid_cols: av.int().min(1).max(64),
|
grid_cols: av.int().min(1).max(64),
|
||||||
grid_rows: av.int().min(1).max(64),
|
grid_rows: av.int().min(1).max(64),
|
||||||
priority: layoutPriority,
|
priority: layoutPriority,
|
||||||
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
||||||
preload_camera_ids: av.array(av.int().min(1)),
|
preload_camera_ids: av.array(av.int().min(1)),
|
||||||
is_default: av.bool(),
|
|
||||||
resets_idle_timer: av.bool(),
|
resets_idle_timer: av.bool(),
|
||||||
|
is_default: av.bool(),
|
||||||
cells: av.array(layoutCell),
|
cells: av.array(layoutCell),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "reject" },
|
{ unknownKeys: "reject" },
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ export interface BundleCamera {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleCell {
|
export interface BundleCell {
|
||||||
region_name: string;
|
row: number;
|
||||||
|
col: number;
|
||||||
|
row_span: number;
|
||||||
|
col_span: number;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
camera_id: number | null;
|
camera_id: number | null;
|
||||||
stream_selector: string | null;
|
stream_selector: string | null;
|
||||||
|
|
@ -43,14 +46,16 @@ export interface BundleCell {
|
||||||
export interface BundleLayout {
|
export interface BundleLayout {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
regions: unknown;
|
/** Computed from cells: max(col + col_span). 1 if no cells. */
|
||||||
grid_cols: number;
|
grid_cols: number;
|
||||||
|
/** Computed from cells: max(row + row_span). 1 if no cells. */
|
||||||
grid_rows: number;
|
grid_rows: number;
|
||||||
priority: string;
|
priority: string;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
preload_camera_ids: number[];
|
preload_camera_ids: number[];
|
||||||
is_default: boolean;
|
|
||||||
resets_idle_timer: boolean;
|
resets_idle_timer: boolean;
|
||||||
|
/** True if the kiosk's display has this layout as its default_layout_id. */
|
||||||
|
is_default: boolean;
|
||||||
cells: BundleCell[];
|
cells: BundleCell[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,21 +102,32 @@ export function generateBundle(
|
||||||
// Collect all cameras referenced by cells in these layouts
|
// Collect all cameras referenced by cells in these layouts
|
||||||
const cameras = repo.camerasForLayoutIds(layoutIds);
|
const cameras = repo.camerasForLayoutIds(layoutIds);
|
||||||
|
|
||||||
|
const defaultLayoutId = display.default_layout_id;
|
||||||
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
||||||
const cells = repo.layoutCells(l.id);
|
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 {
|
return {
|
||||||
id: l.id,
|
id: l.id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
regions: l.regions,
|
grid_cols: gridCols,
|
||||||
grid_cols: l.grid_cols,
|
grid_rows: gridRows,
|
||||||
grid_rows: l.grid_rows,
|
|
||||||
priority: l.priority,
|
priority: l.priority,
|
||||||
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||||
preload_camera_ids: l.preload_camera_ids,
|
preload_camera_ids: l.preload_camera_ids,
|
||||||
is_default: l.is_default,
|
|
||||||
resets_idle_timer: l.resets_idle_timer,
|
resets_idle_timer: l.resets_idle_timer,
|
||||||
|
is_default: defaultLayoutId === l.id,
|
||||||
cells: cells.map((c) => ({
|
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,
|
content_type: c.content_type,
|
||||||
camera_id: c.camera_id,
|
camera_id: c.camera_id,
|
||||||
stream_selector: c.stream_selector,
|
stream_selector: c.stream_selector,
|
||||||
|
|
|
||||||
|
|
@ -141,13 +141,19 @@ export interface Layout {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
template_id: number | null; // deprecated — kept nullable for backward compat
|
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[];
|
regions: LayoutRegion[];
|
||||||
|
/** @deprecated Computed from cells: max(col + col_span). */
|
||||||
grid_cols: number;
|
grid_cols: number;
|
||||||
|
/** @deprecated Computed from cells: max(row + row_span). */
|
||||||
grid_rows: number;
|
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;
|
priority: LayoutPriority;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
preload_camera_ids: number[];
|
preload_camera_ids: number[];
|
||||||
|
/** @deprecated Per-display defaults live on `display.default_layout_id`. */
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
resets_idle_timer: boolean;
|
resets_idle_timer: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +161,12 @@ export interface Layout {
|
||||||
export interface LayoutCell {
|
export interface LayoutCell {
|
||||||
id: number;
|
id: number;
|
||||||
layout_id: number;
|
layout_id: number;
|
||||||
|
/** @deprecated Cells own their position via row/col/row_span/col_span now. */
|
||||||
region_name: string;
|
region_name: string;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
row_span: number;
|
||||||
|
col_span: number;
|
||||||
content_type: CellContentType;
|
content_type: CellContentType;
|
||||||
camera_id: number | null;
|
camera_id: number | null;
|
||||||
stream_selector: StreamSelector;
|
stream_selector: StreamSelector;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import type {
|
||||||
Label,
|
Label,
|
||||||
Layout as LayoutType,
|
Layout as LayoutType,
|
||||||
LayoutCell,
|
LayoutCell,
|
||||||
LayoutRegion,
|
|
||||||
PairingCode,
|
PairingCode,
|
||||||
EventLog,
|
EventLog,
|
||||||
} from "../shared/types.js";
|
} from "../shared/types.js";
|
||||||
|
|
@ -879,7 +878,8 @@ export function LabelsPage(props: LabelsPageProps) {
|
||||||
interface LayoutsPageProps {
|
interface LayoutsPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
layouts: LayoutType[];
|
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) {
|
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>
|
<a href="/admin/layouts/new" class="btn btn-primary">New Layout</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:#666; margin-bottom:1.25rem">
|
<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>
|
</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Grid</th>
|
<th>Displays</th>
|
||||||
<th>Display</th>
|
|
||||||
<th>Priority</th>
|
<th>Priority</th>
|
||||||
<th>Default</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.layouts.length === 0 ? (
|
{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) => {
|
props.layouts.map((l) => {
|
||||||
const disp = props.displays.get(l.display_id);
|
const count = props.displayCounts.get(l.id) ?? 0;
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
<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>
|
||||||
<td>{disp ? disp.name : `#${String(l.display_id)}`}</td>
|
{count === 0
|
||||||
|
? <span style="color:#999">unattached</span>
|
||||||
|
: <span>{String(count)} display{count !== 1 ? "s" : ""}</span>}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{l.priority === "hot"
|
{l.priority === "hot"
|
||||||
? <span class="badge badge-red">hot</span>
|
? <span class="badge badge-red">hot</span>
|
||||||
|
|
@ -922,9 +925,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
|
||||||
: <span class="badge badge-gray">normal</span>
|
: <span class="badge badge-gray">normal</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{l.is_default ? <span class="badge badge-green">Yes</span> : ""}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -940,7 +940,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
|
||||||
|
|
||||||
interface LayoutNewPageProps {
|
interface LayoutNewPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
displays: Display[];
|
|
||||||
error?: string;
|
error?: string;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
@ -954,85 +953,21 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
||||||
activeNav="layouts"
|
activeNav="layouts"
|
||||||
flash={props.error ? { type: "error", message: props.error } : undefined}
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
>
|
>
|
||||||
<div style="max-width:700px">
|
<div style="max-width:600px">
|
||||||
{/* Quick presets */}
|
<p style="color:#666; margin-bottom:1.25rem">
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
Create an empty layout. You'll add cells visually on the next page,
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Quick Create from Preset</h2>
|
then attach the layout to one or more displays.
|
||||||
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
</p>
|
||||||
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 class="card">
|
<div class="card">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Custom Layout</h2>
|
|
||||||
<form method="post" action="/admin/layouts/new">
|
<form method="post" action="/admin/layouts/new">
|
||||||
<input type="hidden" name="preset" value="custom" />
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Layout Name</label>
|
<label for="name">Layout Name</label>
|
||||||
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="display_id">Display</label>
|
<label for="description">Description (optional)</label>
|
||||||
<select id="display_id" name="display_id" class="form-input" required>
|
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -1044,18 +979,6 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="resets_idle_timer" value="1" checked={v["resets_idle_timer"] !== "0"} />
|
<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 {
|
interface LayoutEditPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
layout: LayoutType;
|
layout: LayoutType;
|
||||||
display: Display;
|
/** Displays this layout is attached to (informational, read-only). */
|
||||||
|
displays: Display[];
|
||||||
cells: LayoutCell[];
|
cells: LayoutCell[];
|
||||||
cameras: Camera[];
|
cameras: Camera[];
|
||||||
|
/** If set, render the content-assignment form for this cell beneath the grid. */
|
||||||
|
selectedCellId?: number | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: 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) {
|
export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
const l = props.layout;
|
const l = props.layout;
|
||||||
// Build a map from region_name → cell for easy lookup
|
const cells = props.cells;
|
||||||
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>();
|
const cameraById = new Map<number, Camera>();
|
||||||
for (const cam of props.cameras) {
|
for (const cam of props.cameras) {
|
||||||
cameraById.set(cam.id, cam);
|
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 (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
title={`Layout: ${l.name}`}
|
title={`Layout: ${l.name}`}
|
||||||
|
|
@ -1108,7 +1072,8 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style="max-width:800px">
|
<style>{LAYOUT_BUILDER_CSS}</style>
|
||||||
|
<div style="max-width:900px">
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Settings</h2>
|
<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" />
|
<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 class="form-hint">How long streams stay warm after leaving this layout. Leave blank for no timeout.</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="resets_idle_timer" value="1" checked={l.resets_idle_timer} />
|
<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>
|
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
</form>
|
</form>
|
||||||
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
|
<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>Grid: {String(gridCols)}x{String(gridRows)}, {String(cells.length)} cell{cells.length !== 1 ? "s" : ""}</div>
|
||||||
<div>Display: <a href={`/admin/displays/${props.display.id}`}>{props.display.name}</a></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid preview with cell assignments */}
|
{/* Visual builder */}
|
||||||
{l.regions.length > 0 && (
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layout Builder</h2>
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid Preview</h2>
|
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
||||||
<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`}>
|
Hover a cell to see <strong>+</strong> buttons (add a neighbour) and the <strong>×</strong> delete button.
|
||||||
{l.regions.map((r) => {
|
Click a cell to assign content.
|
||||||
const cell = cellByRegion.get(r.name);
|
</p>
|
||||||
let label = r.name;
|
{cells.length === 0 ? (
|
||||||
let bgColor = "#f9fafb";
|
<div class="layout-empty">
|
||||||
let textColor = "#666";
|
<form method="post" action={`/admin/layouts/${l.id}/cells`} style="margin:0">
|
||||||
if (cell) {
|
<input type="hidden" name="row" value="0" />
|
||||||
bgColor = "#dbeafe";
|
<input type="hidden" name="col" value="0" />
|
||||||
textColor = "#1e40af";
|
<button type="submit" class="layout-empty-add" title="Add first cell">+</button>
|
||||||
if (cell.content_type === "camera" && cell.camera_id) {
|
</form>
|
||||||
const cam = cameraById.get(cell.camera_id);
|
</div>
|
||||||
label = cam ? cam.name : `cam #${String(cell.camera_id)}`;
|
) : (
|
||||||
} else if (cell.content_type === "web") {
|
<div class="layout-builder" style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}>
|
||||||
label = "Web";
|
{cells.map((c) => {
|
||||||
} else if (cell.content_type === "html") {
|
const isSelected = selectedCell?.id === c.id;
|
||||||
label = "HTML";
|
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 (
|
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`}>
|
<a
|
||||||
{label}
|
href={`/admin/layouts/${l.id}?cell=${String(c.id)}`}
|
||||||
</div>
|
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>
|
||||||
|
)}
|
||||||
|
</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>
|
</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">
|
<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>
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this layout and all its cells?')"}}>Delete Layout</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -1359,7 +1297,10 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
interface DisplayEditPageProps {
|
interface DisplayEditPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
display: Display;
|
display: Display;
|
||||||
layouts: LayoutType[];
|
/** Layouts currently attached to this display. */
|
||||||
|
attachedLayouts: LayoutType[];
|
||||||
|
/** All other layouts that could be attached. */
|
||||||
|
availableLayouts: LayoutType[];
|
||||||
kioskName?: string | null;
|
kioskName?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: 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>
|
<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 style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
||||||
<div>Index: {String(d.index)}</div>
|
<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 && (
|
{d.kiosk_id && (
|
||||||
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
|
<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>
|
<label for="default_layout_id">Default Layout</label>
|
||||||
<select id="default_layout_id" name="default_layout_id" class="form-input">
|
<select id="default_layout_id" name="default_layout_id" class="form-input">
|
||||||
<option value="">-- None --</option>
|
<option value="">-- None --</option>
|
||||||
{props.layouts.map((l) => (
|
{props.attachedLayouts.map((l) => (
|
||||||
<option value={String(l.id)} selected={d.default_layout_id === l.id}>
|
<option value={String(l.id)} selected={d.default_layout_id === l.id}>
|
||||||
{l.name}
|
{l.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div class="form-group">
|
<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 class="form-hint">Send CEC standby after this many seconds of inactivity. 0 to disable.</div>
|
||||||
</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>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
<a href="/admin/displays" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
<a href="/admin/displays" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.layouts.length > 0 && (
|
{/* Layout attachments */}
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layouts on This Display</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Available Layouts</h2>
|
||||||
<div class="table-wrap">
|
<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>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Priority</th>
|
<th>Priority</th>
|
||||||
<th>Default</th>
|
<th>Default</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.layouts.map((l) => (
|
{props.attachedLayouts.map((l) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
<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><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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue