mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
refactor: merge templates into layouts, displays from kiosks
- Eliminated layout_templates as separate entity — regions/grid now live directly on layouts - Displays created from kiosk pairing (not standalone), each display has kiosk_id FK - Removed Templates from sidebar nav and all template routes/pages - Layout creation uses preset buttons (fullscreen, 2x2, 1+3, 3x3) that set regions directly on the layout - Setup no longer creates default display/layout (deferred to pairing) - Pairing creates HDMI-0 display for new kiosk - Bundle reads regions from layout directly, no template lookup - Rust kiosk updated to match new bundle format - DB migration adds regions/grid_cols/grid_rows to layouts, kiosk_id to displays, copies existing template data
This commit is contained in:
parent
72d8ad717f
commit
7fbda3c2b3
15 changed files with 500 additions and 531 deletions
143
docs/ARCHITECTURE.md
Normal file
143
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# BetterFrame — Architecture
|
||||
|
||||
## Goals
|
||||
|
||||
- Display up to 32 cameras simultaneously on a Pi 5 driving HDMI.
|
||||
- Mixed cells: cameras, web pages (iframe), and custom HTML.
|
||||
- Layouts switch with no perceptible latency, driven by API or camera events.
|
||||
- Layout templates (named regions) compile to a pixel grid at runtime.
|
||||
- Cameras configured via raw RTSP or ONVIF (auto-discover streams + capabilities).
|
||||
- API-key-protected REST API for everything except local kiosk reads.
|
||||
- Single display in v1; data model already supports multi-display.
|
||||
|
||||
## Process layout
|
||||
|
||||
Two processes on the Pi, coordinating over a local WebSocket:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Raspberry Pi 5 │
|
||||
│ │
|
||||
│ ┌────────────────────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ Kiosk (Rust + GTK4) │ │ Backend (FastAPI) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Decoder pool (warm/hot) │◄───┤ - SQLite │ │
|
||||
│ │ Grid renderer (GTK4) │ │ - ONVIF service │ │
|
||||
│ │ WebKitGTK pool │ WS │ - Layout API │ │
|
||||
│ │ │ │ - Event rules engine │ │
|
||||
│ └──────────────┬─────────────┘ │ - API key auth │ │
|
||||
│ │ │ - Static admin UI │ │
|
||||
│ │ RTSP └────────────┬──────────────┘ │
|
||||
└─────────────────┼───────────────────────────────┼────────────────┘
|
||||
▼ │
|
||||
┌─────────────────┐ ▼
|
||||
│ IP cameras │ LAN clients (port 8080)
|
||||
│ RTSP / ONVIF │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Why these choices
|
||||
|
||||
**Rust kiosk + Python backend.** Rust where the latency budget is tight
|
||||
(pipeline state changes, decoder management, render loop). Python where the
|
||||
ecosystem matters (`onvif-zeep`, FastAPI, alembic). They communicate via
|
||||
WebSocket so neither is locked to the other's runtime.
|
||||
|
||||
**SQLite, not Postgres.** Total dataset is hundreds of rows. WAL mode handles
|
||||
the kiosk-as-reader case fine, atomic schema migrations are easy, single-file
|
||||
backup is trivial.
|
||||
|
||||
**GStreamer for video.** Only realistic choice on Linux for hardware-accelerated
|
||||
multi-camera. Pi 5 V4L2 M2M decoder is exposed via `v4l2h264dec`; `gstreamer-rs`
|
||||
bindings are mature.
|
||||
|
||||
## Stream warmth model
|
||||
|
||||
Each `(camera_id, stream_type)` pair is in one of four states:
|
||||
|
||||
| State | RTSP open | Decoder running | Visible | Promote cost |
|
||||
|----------|-----------|-----------------|---------|--------------|
|
||||
| Hot | yes | yes | yes | 0 |
|
||||
| Warm | yes | yes (paused) | no | ~1 frame |
|
||||
| Cooling | yes | yes | no | 0 |
|
||||
| Cold | no | no | no | 1-3 seconds |
|
||||
|
||||
The kiosk computes the needed warm set on every layout activation:
|
||||
|
||||
```
|
||||
warm_set =
|
||||
streams_used_by_active_layout
|
||||
∪ streams_in_layout_preload_list
|
||||
∪ streams_used_by_priority_hot_layouts (always-on)
|
||||
∪ streams_currently_in_cooling_window
|
||||
```
|
||||
|
||||
Anything outside that set transitions to cooling, then cold when its timeout
|
||||
expires.
|
||||
|
||||
## Layout templates
|
||||
|
||||
Templates define named regions in a normalized 12×12 grid. Layouts reference a
|
||||
template and bind cameras or content to its named regions.
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
- id: 1-big-7-small
|
||||
regions:
|
||||
- { name: main, x: 0, y: 0, w: 8, h: 8 }
|
||||
- { name: tr-1, x: 8, y: 0, w: 4, h: 2 }
|
||||
- { name: tr-2, x: 8, y: 2, w: 4, h: 2 }
|
||||
# ...
|
||||
|
||||
layouts:
|
||||
- id: front-overview
|
||||
template_id: 1-big-7-small
|
||||
bindings:
|
||||
main: { type: camera, camera_id: 1, stream: main }
|
||||
tr-1: { type: camera, camera_id: 2, stream: sub }
|
||||
br-3: { type: web, url: "http://homeassistant.local/dashboard" }
|
||||
priority: hot
|
||||
cooling_timeout_seconds: 300
|
||||
preload_camera_ids: [4, 5]
|
||||
```
|
||||
|
||||
Templates compile to pixel rectangles at the kiosk based on actual display
|
||||
resolution. Cells under 20% of total display area default to sub-stream;
|
||||
≥20% default to main; per-cell override always wins.
|
||||
|
||||
## Event rules engine
|
||||
|
||||
ONVIF cameras with event support get a persistent PullPoint subscription managed
|
||||
by the backend. Events are normalized to `{camera_id, topic, payload}` and
|
||||
matched against rules:
|
||||
|
||||
```yaml
|
||||
event_rules:
|
||||
- when:
|
||||
camera_id: 5
|
||||
topic: "tns1:RuleEngine/CellMotionDetector/Motion"
|
||||
property_op: "Changed"
|
||||
do:
|
||||
action: activate_layout
|
||||
layout_id: front-door-zoom
|
||||
revert_after_seconds: 60
|
||||
revert_to: previous
|
||||
cooldown_seconds: 30
|
||||
```
|
||||
|
||||
External systems fire synthetic events via `POST /api/events/trigger`, so
|
||||
non-ONVIF inputs work through the same engine.
|
||||
|
||||
## Auth
|
||||
|
||||
- **Kiosk → backend**: WebSocket on `127.0.0.1:8000`, no auth (loopback only).
|
||||
- **LAN → backend**: HTTP on `0.0.0.0:8080`, every route requires `X-API-Key`.
|
||||
|
||||
Two listeners, two middleware stacks, same FastAPI app.
|
||||
|
||||
## Multi-display readiness
|
||||
|
||||
Schema includes `display_id` on `layouts` and a `displays` table. v1 hard-codes
|
||||
a single display row. The kiosk↔backend protocol includes `display_id` in
|
||||
every activation message. Adding a second display later: new `displays` row,
|
||||
new kiosk instance bound to it, no API changes.
|
||||
|
|
@ -25,7 +25,9 @@ pub struct BundleDisplay {
|
|||
pub struct BundleLayout {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub template: Option<BundleTemplate>,
|
||||
pub regions: Vec<BundleRegion>,
|
||||
pub grid_cols: u32,
|
||||
pub grid_rows: u32,
|
||||
pub priority: String,
|
||||
pub cooling_timeout_seconds: Option<u32>,
|
||||
pub preload_camera_ids: Vec<u32>,
|
||||
|
|
@ -34,15 +36,6 @@ pub struct BundleLayout {
|
|||
pub cells: Vec<BundleCell>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleTemplate {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub regions: Vec<BundleRegion>,
|
||||
pub grid_cols: u32,
|
||||
pub grid_rows: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleRegion {
|
||||
pub name: String,
|
||||
|
|
|
|||
|
|
@ -120,14 +120,14 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
|||
return;
|
||||
};
|
||||
|
||||
let Some(ref template) = layout.template else {
|
||||
warn!("layout has no template");
|
||||
if layout.regions.is_empty() {
|
||||
warn!("layout has no regions");
|
||||
show_logo(window);
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
info!("rendering layout '{}' with {}x{} grid, {} cells",
|
||||
layout.name, template.grid_cols, template.grid_rows, layout.cells.len());
|
||||
layout.name, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
||||
|
||||
let grid = Grid::new();
|
||||
grid.set_row_homogeneous(true);
|
||||
|
|
@ -141,9 +141,9 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
|||
let pipelines: Rc<RefCell<Vec<gstreamer::Pipeline>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
for cell in &layout.cells {
|
||||
let region = template.regions.iter().find(|r| r.name == cell.region_name);
|
||||
let region = layout.regions.iter().find(|r| r.name == cell.region_name);
|
||||
let Some(region) = region else {
|
||||
warn!("region '{}' not found in template", cell.region_name);
|
||||
warn!("region '{}' not found in layout", cell.region_name);
|
||||
continue;
|
||||
};
|
||||
|
||||
|
|
@ -200,7 +200,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
|||
}
|
||||
|
||||
// Fill empty regions
|
||||
for region in &template.regions {
|
||||
for region in &layout.regions {
|
||||
if !layout.cells.iter().any(|c| c.region_name == region.name) {
|
||||
let empty = GtkBox::new(Orientation::Vertical, 0);
|
||||
add_css(&empty, "box { background-color: #111; }");
|
||||
|
|
|
|||
|
|
@ -13,16 +13,13 @@ import {
|
|||
KiosksPage,
|
||||
KioskEditPage,
|
||||
LabelsPage,
|
||||
TemplatesPage,
|
||||
TemplateNewPage,
|
||||
TemplateEditPage,
|
||||
LayoutsPage,
|
||||
LayoutNewPage,
|
||||
LayoutEditPage,
|
||||
DisplaysPage,
|
||||
DisplayEditPage,
|
||||
} from "../../web-templates/admin-pages.js";
|
||||
import type { LayoutTemplate, Display } from "../../shared/types.js";
|
||||
import type { Display } from "../../shared/types.js";
|
||||
|
||||
function sanitizeRtspUrl(raw: string): string {
|
||||
const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/);
|
||||
|
|
@ -196,33 +193,47 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||
});
|
||||
|
||||
// ---- Templates (Layout Templates) ------------------------------------------
|
||||
// ---- Layouts ---------------------------------------------------------------
|
||||
|
||||
app.get("/admin/templates", (event) => {
|
||||
app.get("/admin/layouts", (event) => {
|
||||
const user = event.context.user!;
|
||||
const templates = deps.repo.listLayoutTemplates();
|
||||
return htmlPage(TemplatesPage({ user: user.username, templates }));
|
||||
const layouts = deps.repo.listLayouts();
|
||||
const displayIds = [...new Set(layouts.map((l) => l.display_id))];
|
||||
const displays = new Map<number, Display>();
|
||||
for (const did of displayIds) {
|
||||
const d = deps.repo.getDisplayById(did);
|
||||
if (d) displays.set(did, d);
|
||||
}
|
||||
return htmlPage(LayoutsPage({ user: user.username, layouts, displays }));
|
||||
});
|
||||
|
||||
app.get("/admin/templates/new", (event) => {
|
||||
app.get("/admin/layouts/new", (event) => {
|
||||
const user = event.context.user!;
|
||||
return htmlPage(TemplateNewPage({ user: user.username }));
|
||||
return htmlPage(LayoutNewPage({
|
||||
user: user.username,
|
||||
displays: deps.repo.listDisplays(),
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/admin/templates/new", async (event) => {
|
||||
app.post("/admin/layouts/new", async (event) => {
|
||||
const user = event.context.user!;
|
||||
const body = await readBody<Record<string, string>>(event);
|
||||
const name = (body?.["name"] ?? "").trim();
|
||||
const preset = body?.["preset"] ?? "custom";
|
||||
let name = (body?.["name"] ?? "").trim();
|
||||
const displayId = parseInt(body?.["display_id"] ?? "", 10);
|
||||
const priority = body?.["priority"] ?? "normal";
|
||||
const description = (body?.["description"] ?? "").trim() || null;
|
||||
const isDefault = body?.["is_default"] === "1";
|
||||
const resetsIdleTimer = body?.["resets_idle_timer"] === "1";
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!name || name.length > 128) {
|
||||
errors.push("Name required (max 128 chars).");
|
||||
}
|
||||
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
|
||||
if (isNaN(displayId)) errors.push("Select a display.");
|
||||
|
||||
let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }> = [];
|
||||
let gridCols = 12;
|
||||
let gridRows = 12;
|
||||
type Region = { name: string; row: number; col: number; rowSpan: number; colSpan: number };
|
||||
let regions: Region[] = [];
|
||||
let gridCols = 1;
|
||||
let gridRows = 1;
|
||||
|
||||
if (preset === "fullscreen") {
|
||||
gridCols = 1;
|
||||
|
|
@ -262,14 +273,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
];
|
||||
} else {
|
||||
// Custom
|
||||
gridCols = parseInt(body?.["grid_cols"] ?? "12", 10);
|
||||
gridRows = parseInt(body?.["grid_rows"] ?? "12", 10);
|
||||
gridCols = parseInt(body?.["grid_cols"] ?? "1", 10);
|
||||
gridRows = parseInt(body?.["grid_rows"] ?? "1", 10);
|
||||
if (isNaN(gridCols) || gridCols < 1 || gridCols > 12) errors.push("Grid columns must be 1-12.");
|
||||
if (isNaN(gridRows) || gridRows < 1 || gridRows > 12) errors.push("Grid rows must be 1-12.");
|
||||
|
||||
const regionsStr = (body?.["regions"] ?? "").trim();
|
||||
if (!regionsStr) {
|
||||
errors.push("Regions JSON is required for custom templates.");
|
||||
errors.push("Regions JSON is required for custom layout.");
|
||||
} else {
|
||||
try {
|
||||
regions = JSON.parse(regionsStr);
|
||||
|
|
@ -282,97 +293,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return htmlPage(TemplateNewPage({
|
||||
user: user.username,
|
||||
error: errors.join(" "),
|
||||
values: body,
|
||||
}));
|
||||
}
|
||||
|
||||
deps.repo.createLayoutTemplate({
|
||||
name,
|
||||
regions,
|
||||
grid_cols: gridCols,
|
||||
grid_rows: gridRows,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
|
||||
});
|
||||
|
||||
app.get("/admin/templates/:id", (event) => {
|
||||
const user = event.context.user!;
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
const template = deps.repo.getLayoutTemplateById(id);
|
||||
if (!template) return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
|
||||
return htmlPage(TemplateEditPage({ user: user.username, template }));
|
||||
});
|
||||
|
||||
app.post("/admin/templates/:id", async (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
const body = await readBody<Record<string, string>>(event);
|
||||
deps.repo.updateLayoutTemplate(id, {
|
||||
name: body?.["name"],
|
||||
description: body?.["description"] || null,
|
||||
});
|
||||
return new Response(null, { status: 302, headers: { location: `/admin/templates/${id}` } });
|
||||
});
|
||||
|
||||
app.post("/admin/templates/:id/delete", (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
deps.repo.deleteLayoutTemplate(id);
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/templates" } });
|
||||
});
|
||||
|
||||
// ---- Layouts ---------------------------------------------------------------
|
||||
|
||||
app.get("/admin/layouts", (event) => {
|
||||
const user = event.context.user!;
|
||||
const layouts = deps.repo.listLayouts();
|
||||
const templateIds = [...new Set(layouts.map((l) => l.template_id))];
|
||||
const displayIds = [...new Set(layouts.map((l) => l.display_id))];
|
||||
const templates = new Map<number, LayoutTemplate>();
|
||||
for (const tid of templateIds) {
|
||||
const t = deps.repo.getLayoutTemplateById(tid);
|
||||
if (t) templates.set(tid, t);
|
||||
}
|
||||
const displays = new Map<number, Display>();
|
||||
for (const did of displayIds) {
|
||||
const d = deps.repo.getDisplayById(did);
|
||||
if (d) displays.set(did, d);
|
||||
}
|
||||
return htmlPage(LayoutsPage({ user: user.username, layouts, templates, displays }));
|
||||
});
|
||||
|
||||
app.get("/admin/layouts/new", (event) => {
|
||||
const user = event.context.user!;
|
||||
return htmlPage(LayoutNewPage({
|
||||
user: user.username,
|
||||
templates: deps.repo.listLayoutTemplates(),
|
||||
displays: deps.repo.listDisplays(),
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/admin/layouts/new", async (event) => {
|
||||
const user = event.context.user!;
|
||||
const body = await readBody<Record<string, string>>(event);
|
||||
const name = (body?.["name"] ?? "").trim();
|
||||
const templateId = parseInt(body?.["template_id"] ?? "", 10);
|
||||
const displayId = parseInt(body?.["display_id"] ?? "", 10);
|
||||
const priority = body?.["priority"] ?? "normal";
|
||||
const description = (body?.["description"] ?? "").trim() || null;
|
||||
const isDefault = body?.["is_default"] === "1";
|
||||
const resetsIdleTimer = body?.["resets_idle_timer"] === "1";
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
|
||||
if (isNaN(templateId)) errors.push("Select a template.");
|
||||
if (isNaN(displayId)) errors.push("Select a display.");
|
||||
|
||||
if (errors.length > 0) {
|
||||
return htmlPage(LayoutNewPage({
|
||||
user: user.username,
|
||||
templates: deps.repo.listLayoutTemplates(),
|
||||
displays: deps.repo.listDisplays(),
|
||||
error: errors.join(" "),
|
||||
values: body,
|
||||
|
|
@ -382,7 +305,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
const layout = deps.repo.createLayout({
|
||||
name,
|
||||
description,
|
||||
template_id: templateId,
|
||||
regions,
|
||||
grid_cols: gridCols,
|
||||
grid_rows: gridRows,
|
||||
display_id: displayId,
|
||||
priority,
|
||||
is_default: isDefault,
|
||||
|
|
@ -397,8 +322,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
const id = Number(getRouterParam(event, "id"));
|
||||
const layout = deps.repo.getLayoutById(id);
|
||||
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||
const template = deps.repo.getLayoutTemplateById(layout.template_id);
|
||||
if (!template) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||
const display = deps.repo.getDisplayById(layout.display_id);
|
||||
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||
const cells = deps.repo.layoutCells(id);
|
||||
|
|
@ -406,7 +329,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
return htmlPage(LayoutEditPage({
|
||||
user: user.username,
|
||||
layout,
|
||||
template,
|
||||
display,
|
||||
cells,
|
||||
cameras,
|
||||
|
|
@ -479,7 +401,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
const display = deps.repo.getDisplayById(id);
|
||||
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
|
||||
const layouts = deps.repo.layoutsForDisplay(id);
|
||||
return htmlPage(DisplayEditPage({ user: user.username, display, layouts }));
|
||||
const kiosk = display.kiosk_id ? deps.repo.getKioskById(display.kiosk_id) : null;
|
||||
return htmlPage(DisplayEditPage({ user: user.username, display, layouts, kioskName: kiosk?.name ?? null }));
|
||||
});
|
||||
|
||||
app.post("/admin/displays/:id", async (event) => {
|
||||
|
|
@ -635,11 +558,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
name: kl.name,
|
||||
role: kl.role,
|
||||
}));
|
||||
const displays = deps.repo.listDisplaysForKiosk(id);
|
||||
return htmlPage(KioskEditPage({
|
||||
user: user.username,
|
||||
kiosk,
|
||||
labels: kioskLabels,
|
||||
allLabels: deps.repo.listLabels(),
|
||||
displays,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -45,30 +45,9 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
|||
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
||||
deps.repo.markClusterKeyProvisioned();
|
||||
|
||||
// Create default display, template, and layout
|
||||
const display = deps.repo.createDefaultDisplay();
|
||||
const template = deps.repo.createLayoutTemplate({
|
||||
name: "Fullscreen",
|
||||
description: "Single region covering the entire display",
|
||||
regions: [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }],
|
||||
grid_cols: 1,
|
||||
grid_rows: 1,
|
||||
is_builtin: true,
|
||||
});
|
||||
const layout = deps.repo.createLayout({
|
||||
name: "Default",
|
||||
description: "Default layout — BetterFrame logo",
|
||||
template_id: template.id,
|
||||
display_id: display.id,
|
||||
is_default: true,
|
||||
});
|
||||
deps.repo.createLayoutCell({
|
||||
layout_id: layout.id,
|
||||
region_name: "main",
|
||||
content_type: "html",
|
||||
html_content: '<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#1a1a2e;color:#fff;font-family:system-ui"><h1 style="font-size:3rem;font-weight:300">BetterFrame</h1></div>',
|
||||
});
|
||||
deps.repo.updateDisplay(display.id, { default_layout_id: layout.id });
|
||||
// Setup only creates admin user + cluster key.
|
||||
// Displays are created when kiosks are paired (kiosk reports HDMI ports).
|
||||
// Layouts are created by admin after pairing.
|
||||
deps.repo.markSetupComplete();
|
||||
|
||||
return new Response(null, {
|
||||
|
|
|
|||
|
|
@ -121,8 +121,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
this.db.exec("PRAGMA busy_timeout = 10000");
|
||||
|
||||
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
|
||||
for (const stmt of MIGRATIONS) {
|
||||
this.db.exec(stmt);
|
||||
for (const entry of MIGRATIONS) {
|
||||
if (typeof entry === "string") {
|
||||
this.db.exec(entry);
|
||||
} else {
|
||||
entry(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
this._repo = new Repository(this.db, async (table, op, id) => {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export function rowToDisplay(r: Row): Display {
|
|||
name: s(r["name"]),
|
||||
index: n(r["index"]),
|
||||
is_primary: b(r["is_primary"]),
|
||||
kiosk_id: nn(r["kiosk_id"]),
|
||||
width_px: n(r["width_px"]),
|
||||
height_px: n(r["height_px"]),
|
||||
default_layout_id: nn(r["default_layout_id"]),
|
||||
|
|
@ -175,7 +176,10 @@ export function rowToLayout(r: Row): Layout {
|
|||
id: n(r["id"]),
|
||||
name: s(r["name"]),
|
||||
description: sn(r["description"]),
|
||||
template_id: n(r["template_id"]),
|
||||
template_id: nn(r["template_id"]),
|
||||
regions: j<LayoutRegion[]>(r["regions"], []),
|
||||
grid_cols: n(r["grid_cols"]) || 1,
|
||||
grid_rows: n(r["grid_rows"]) || 1,
|
||||
display_id: n(r["display_id"]),
|
||||
priority: s(r["priority"]) as LayoutPriority,
|
||||
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,25 @@
|
|||
* SQLAlchemy's DateTime adapter — we avoid the whole class of issue here.)
|
||||
*/
|
||||
|
||||
export const MIGRATIONS: readonly string[] = [
|
||||
/**
|
||||
* A migration entry: either a plain SQL string or a function receiving the DB.
|
||||
* Functions are used for ALTER TABLE which lacks IF NOT EXISTS in SQLite.
|
||||
*/
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
export type MigrationEntry = string | ((db: DatabaseSync) => void);
|
||||
|
||||
function addColumnIfNotExists(
|
||||
db: DatabaseSync,
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
): void {
|
||||
const cols = db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>;
|
||||
if (cols.some((c) => c.name === column)) return;
|
||||
db.exec(`ALTER TABLE "${table}" ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||
// ---- users ---------------------------------------------------------------
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -249,4 +267,21 @@ export const MIGRATIONS: readonly string[] = [
|
|||
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_log_received ON event_log(received_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`,
|
||||
|
||||
// ---- v0.2: flatten layout_templates into layouts, display→kiosk inversion ---
|
||||
(db: DatabaseSync) => {
|
||||
addColumnIfNotExists(db, "layouts", "regions", "TEXT NOT NULL DEFAULT '[]'");
|
||||
addColumnIfNotExists(db, "layouts", "grid_cols", "INTEGER NOT NULL DEFAULT 1");
|
||||
addColumnIfNotExists(db, "layouts", "grid_rows", "INTEGER NOT NULL DEFAULT 1");
|
||||
|
||||
// Copy template data into layouts (idempotent — only updates rows where regions is still '[]')
|
||||
db.exec(`UPDATE layouts SET
|
||||
regions = COALESCE((SELECT lt.regions FROM layout_templates lt WHERE lt.id = layouts.template_id), '[]'),
|
||||
grid_cols = COALESCE((SELECT lt.grid_cols FROM layout_templates lt WHERE lt.id = layouts.template_id), 1),
|
||||
grid_rows = COALESCE((SELECT lt.grid_rows FROM layout_templates lt WHERE lt.id = layouts.template_id), 1)
|
||||
WHERE regions = '[]' AND template_id IS NOT NULL`);
|
||||
|
||||
addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL");
|
||||
},
|
||||
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ export class Repository {
|
|||
createDefaultDisplay(): Display {
|
||||
const result = this.prep(
|
||||
`INSERT INTO displays (name, "index", is_primary)
|
||||
VALUES ('primary', 0, 1)`,
|
||||
VALUES ('primary', 0, 0)`,
|
||||
).run();
|
||||
const id = Number(result.lastInsertRowid);
|
||||
void this.notify("displays", "create", id);
|
||||
|
|
@ -359,6 +359,43 @@ export class Repository {
|
|||
return d;
|
||||
}
|
||||
|
||||
createDisplayForKiosk(kioskId: number, input: {
|
||||
name: string;
|
||||
index?: number;
|
||||
width_px?: number;
|
||||
height_px?: number;
|
||||
}): Display {
|
||||
// Find next available index
|
||||
const idx = input.index ?? this.nextDisplayIndex();
|
||||
const result = this.prep(
|
||||
`INSERT INTO displays (name, "index", is_primary, kiosk_id, width_px, height_px)
|
||||
VALUES (?, ?, 0, ?, ?, ?)`,
|
||||
).run(
|
||||
input.name,
|
||||
idx,
|
||||
kioskId,
|
||||
input.width_px ?? 1920,
|
||||
input.height_px ?? 1080,
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
void this.notify("displays", "create", id);
|
||||
const d = this.getDisplayById(id);
|
||||
if (!d) throw new Error("display vanished after insert");
|
||||
return d;
|
||||
}
|
||||
|
||||
listDisplaysForKiosk(kioskId: number): Display[] {
|
||||
const rs = this.prep(
|
||||
'SELECT * FROM displays WHERE kiosk_id = ? ORDER BY "index"',
|
||||
).all(kioskId);
|
||||
return rs.map((r) => rowToDisplay(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
private nextDisplayIndex(): number {
|
||||
const r = this.prep('SELECT MAX("index") AS m FROM displays').get() as { m: number | null } | undefined;
|
||||
return (r?.m ?? -1) + 1;
|
||||
}
|
||||
|
||||
updateDisplay(id: number, patch: Partial<Display>): void {
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
|
|
@ -457,7 +494,10 @@ export class Repository {
|
|||
createLayout(input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
template_id: number;
|
||||
template_id?: number | null;
|
||||
regions: unknown;
|
||||
grid_cols: number;
|
||||
grid_rows: number;
|
||||
display_id: number;
|
||||
priority?: string;
|
||||
cooling_timeout_seconds?: number | null;
|
||||
|
|
@ -466,12 +506,15 @@ export class Repository {
|
|||
resets_idle_timer?: boolean;
|
||||
}): Layout {
|
||||
const result = this.prep(
|
||||
`INSERT INTO layouts (name, description, template_id, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO layouts (name, description, template_id, regions, grid_cols, grid_rows, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
input.name,
|
||||
input.description ?? null,
|
||||
input.template_id,
|
||||
input.template_id ?? null,
|
||||
J(input.regions),
|
||||
input.grid_cols,
|
||||
input.grid_rows,
|
||||
input.display_id,
|
||||
input.priority ?? "normal",
|
||||
input.cooling_timeout_seconds ?? null,
|
||||
|
|
@ -492,7 +535,7 @@ export class Repository {
|
|||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (k === "id") continue;
|
||||
sets.push(`${k} = ?`);
|
||||
if (k === "preload_camera_ids") vals.push(J(v));
|
||||
if (k === "preload_camera_ids" || k === "regions") vals.push(J(v));
|
||||
else if (typeof v === "boolean") vals.push(B(v));
|
||||
else vals.push(v === undefined ? null : v);
|
||||
}
|
||||
|
|
@ -810,19 +853,17 @@ export class Repository {
|
|||
key_prefix: string;
|
||||
capabilities?: string[];
|
||||
hardware_model?: string | null;
|
||||
display_id?: number | null;
|
||||
}): Kiosk {
|
||||
const result = this.prep(
|
||||
`INSERT INTO kiosks
|
||||
(name, key_hash, key_prefix, capabilities, hardware_model, display_id, paired_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
input.name,
|
||||
input.key_hash,
|
||||
input.key_prefix,
|
||||
J(input.capabilities ?? []),
|
||||
input.hardware_model ?? null,
|
||||
input.display_id ?? null,
|
||||
isoNow(),
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
|
|
|
|||
|
|
@ -58,24 +58,13 @@ const camera = av.object(
|
|||
{ unknownKeys: "reject" },
|
||||
);
|
||||
|
||||
const layoutTemplate = av.object(
|
||||
const layoutRegion = av.object(
|
||||
{
|
||||
id: av.int().min(1),
|
||||
name: av.string().minLength(1).maxLength(128),
|
||||
regions: av.array(
|
||||
av.object(
|
||||
{
|
||||
name: av.string().minLength(1).maxLength(64),
|
||||
row: av.int().min(0).max(11),
|
||||
col: av.int().min(0).max(11),
|
||||
rowSpan: av.int().min(1).max(12),
|
||||
colSpan: av.int().min(1).max(12),
|
||||
},
|
||||
{ unknownKeys: "reject" },
|
||||
),
|
||||
),
|
||||
grid_cols: av.int().min(1).max(64),
|
||||
grid_rows: av.int().min(1).max(64),
|
||||
name: av.string().minLength(1).maxLength(64),
|
||||
row: av.int().min(0).max(11),
|
||||
col: av.int().min(0).max(11),
|
||||
rowSpan: av.int().min(1).max(12),
|
||||
colSpan: av.int().min(1).max(12),
|
||||
},
|
||||
{ unknownKeys: "reject" },
|
||||
);
|
||||
|
|
@ -98,8 +87,9 @@ const layout = av.object(
|
|||
{
|
||||
id: av.int().min(1),
|
||||
name: av.string().minLength(1).maxLength(128),
|
||||
template_id: av.int().min(1),
|
||||
display_id: av.int().min(1),
|
||||
regions: av.array(layoutRegion),
|
||||
grid_cols: av.int().min(1).max(64),
|
||||
grid_rows: av.int().min(1).max(64),
|
||||
priority: layoutPriority,
|
||||
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
||||
preload_camera_ids: av.array(av.int().min(1)),
|
||||
|
|
@ -117,7 +107,6 @@ export const kioskBundle = av.object(
|
|||
labels: av.array(av.string()),
|
||||
operate_labels: av.array(av.string()),
|
||||
cameras: av.array(camera),
|
||||
templates: av.array(layoutTemplate),
|
||||
layouts: av.array(layout),
|
||||
version: av.string().minLength(1).maxLength(64),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,13 +43,9 @@ export interface BundleCell {
|
|||
export interface BundleLayout {
|
||||
id: number;
|
||||
name: string;
|
||||
template: {
|
||||
id: number;
|
||||
name: string;
|
||||
regions: unknown;
|
||||
grid_cols: number;
|
||||
grid_rows: number;
|
||||
} | null;
|
||||
regions: unknown;
|
||||
grid_cols: number;
|
||||
grid_rows: number;
|
||||
priority: string;
|
||||
cooling_timeout_seconds: number | null;
|
||||
preload_camera_ids: number[];
|
||||
|
|
@ -84,9 +80,15 @@ export function generateBundle(
|
|||
clusterKey: string | undefined,
|
||||
): KioskBundle | null {
|
||||
const kiosk = repo.getKioskById(kioskId);
|
||||
if (!kiosk || !kiosk.display_id) return null;
|
||||
if (!kiosk) return null;
|
||||
|
||||
const display = repo.getDisplayById(kiosk.display_id);
|
||||
// Find display for this kiosk (displays now point to kiosks via kiosk_id)
|
||||
const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
|
||||
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
|
||||
let display = kioskDisplays[0] ?? null;
|
||||
if (!display && kiosk.display_id) {
|
||||
display = repo.getDisplayById(kiosk.display_id);
|
||||
}
|
||||
if (!display) return null;
|
||||
|
||||
const layouts = repo.layoutsForDisplayId(display.id);
|
||||
|
|
@ -97,17 +99,12 @@ export function generateBundle(
|
|||
|
||||
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
||||
const cells = repo.layoutCells(l.id);
|
||||
const template = l.template_id ? repo.getLayoutTemplateById(l.template_id) : null;
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
template: template ? {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
regions: template.regions,
|
||||
grid_cols: template.grid_cols,
|
||||
grid_rows: template.grid_rows,
|
||||
} : null,
|
||||
regions: l.regions,
|
||||
grid_cols: l.grid_cols,
|
||||
grid_rows: l.grid_rows,
|
||||
priority: l.priority,
|
||||
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||
preload_camera_ids: l.preload_camera_ids,
|
||||
|
|
|
|||
|
|
@ -123,17 +123,17 @@ export async function confirmPairing(
|
|||
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
||||
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
||||
|
||||
// Auto-assign to primary display
|
||||
const displays = repo.listDisplays();
|
||||
const primaryDisplay = displays.find((d) => d.is_primary) ?? displays[0];
|
||||
|
||||
const kiosk = repo.createKiosk({
|
||||
name: kioskName,
|
||||
key_hash: kioskKeyHash,
|
||||
key_prefix: kioskKeyPrefix,
|
||||
capabilities: pc.kiosk_capabilities,
|
||||
hardware_model: pc.kiosk_hardware_model,
|
||||
display_id: primaryDisplay?.id ?? null,
|
||||
});
|
||||
|
||||
// Create a default display for this kiosk (HDMI-0)
|
||||
repo.createDisplayForKiosk(kiosk.id, {
|
||||
name: `${kioskName} HDMI-0`,
|
||||
});
|
||||
|
||||
// Attach initial labels
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ export interface Display {
|
|||
id: number;
|
||||
name: string;
|
||||
index: number; // unique
|
||||
is_primary: boolean;
|
||||
is_primary: boolean; // deprecated — kept for backward compat, not used
|
||||
kiosk_id: number | null; // FK → kiosks; displays belong to kiosks
|
||||
width_px: number;
|
||||
height_px: number;
|
||||
default_layout_id: number | null;
|
||||
|
|
@ -139,7 +140,10 @@ export interface Layout {
|
|||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
template_id: number;
|
||||
template_id: number | null; // deprecated — kept nullable for backward compat
|
||||
regions: LayoutRegion[];
|
||||
grid_cols: number;
|
||||
grid_rows: number;
|
||||
display_id: number;
|
||||
priority: LayoutPriority;
|
||||
cooling_timeout_seconds: number | null;
|
||||
|
|
@ -175,7 +179,7 @@ export interface Kiosk {
|
|||
paired_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
last_bundle_version: string | null;
|
||||
display_id: number | null;
|
||||
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
Layout as LayoutType,
|
||||
LayoutCell,
|
||||
LayoutRegion,
|
||||
LayoutTemplate,
|
||||
PairingCode,
|
||||
EventLog,
|
||||
} from "../shared/types.js";
|
||||
|
|
@ -712,6 +711,7 @@ interface KioskEditProps {
|
|||
kiosk: Kiosk;
|
||||
labels: Array<{ label_id: number; name: string; role: string }>;
|
||||
allLabels: Label[];
|
||||
displays?: Display[];
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
|
@ -753,6 +753,29 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Associated displays */}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Displays</h2>
|
||||
{props.displays && props.displays.length > 0 ? (
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Resolution</th><th>Index</th></tr></thead>
|
||||
<tbody>
|
||||
{props.displays.map((d) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td>
|
||||
<td>{String(d.width_px)}x{String(d.height_px)}</td>
|
||||
<td>{String(d.index)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p style="color:#999">No displays associated with this kiosk</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||
{props.labels.length > 0 ? (
|
||||
|
|
@ -851,252 +874,11 @@ export function LabelsPage(props: LabelsPageProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// ---- Templates (Layout Templates) -------------------------------------------
|
||||
|
||||
interface TemplatesPageProps {
|
||||
user: string;
|
||||
templates: LayoutTemplate[];
|
||||
}
|
||||
|
||||
export function TemplatesPage(props: TemplatesPageProps) {
|
||||
return (
|
||||
<Layout title="Layout Templates" user={props.user} activeNav="templates">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">All Templates</h2>
|
||||
<a href="/admin/templates/new" class="btn btn-primary">New Template</a>
|
||||
</div>
|
||||
<p style="color:#666; margin-bottom:1.25rem">
|
||||
Templates define named regions on a grid. Layouts bind content into these regions.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Grid</th>
|
||||
<th>Regions</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.templates.length === 0 ? (
|
||||
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No templates created yet</td></tr>
|
||||
) : (
|
||||
props.templates.map((t) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/templates/${t.id}`}><strong>{t.name}</strong></a></td>
|
||||
<td>{String(t.grid_cols)}x{String(t.grid_rows)}</td>
|
||||
<td>{String(t.regions.length)} region{t.regions.length !== 1 ? "s" : ""}</td>
|
||||
<td>
|
||||
{t.is_builtin
|
||||
? <span class="badge badge-gray">Built-in</span>
|
||||
: <span class="badge badge-blue">Custom</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Template New -----------------------------------------------------------
|
||||
|
||||
interface TemplateNewPageProps {
|
||||
user: string;
|
||||
error?: string;
|
||||
values?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function TemplateNewPage(props: TemplateNewPageProps) {
|
||||
const v = props.values ?? {};
|
||||
return (
|
||||
<Layout
|
||||
title="New Template"
|
||||
user={props.user}
|
||||
activeNav="templates"
|
||||
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||
>
|
||||
<div style="max-width:700px">
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Choose a Preset</h2>
|
||||
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
||||
Pick a preset to get started quickly, or choose Custom to define your own regions.
|
||||
</p>
|
||||
<div class="stats-grid" style="margin-bottom:0">
|
||||
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||
<input type="hidden" name="preset" value="fullscreen" />
|
||||
<input type="hidden" name="name" value="Fullscreen" />
|
||||
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||
<strong>Fullscreen</strong>
|
||||
<div style="color:#666; font-size:0.8rem">1x1 grid, single region</div>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||
<input type="hidden" name="preset" value="2x2" />
|
||||
<input type="hidden" name="name" value="2x2 Grid" />
|
||||
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||
<strong>2x2 Grid</strong>
|
||||
<div style="color:#666; font-size:0.8rem">4 equal regions</div>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||
<input type="hidden" name="preset" value="1plus3" />
|
||||
<input type="hidden" name="name" value="1+3" />
|
||||
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||
<strong>1+3</strong>
|
||||
<div style="color:#666; font-size:0.8rem">Large left, 3 stacked right</div>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/templates/new" style="margin:0">
|
||||
<input type="hidden" name="preset" value="3x3" />
|
||||
<input type="hidden" name="name" value="3x3 Grid" />
|
||||
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||
<strong>3x3 Grid</strong>
|
||||
<div style="color:#666; font-size:0.8rem">9 equal regions</div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Custom Template</h2>
|
||||
<form method="post" action="/admin/templates/new">
|
||||
<input type="hidden" name="preset" value="custom" />
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grid_cols">Grid Columns</label>
|
||||
<input id="grid_cols" name="grid_cols" type="number" class="form-input" min="1" max="12" value={v["grid_cols"] ?? "12"} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grid_rows">Grid Rows</label>
|
||||
<input id="grid_rows" name="grid_rows" type="number" class="form-input" min="1" max="12" value={v["grid_rows"] ?? "12"} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="regions">Regions (JSON)</label>
|
||||
<textarea
|
||||
id="regions"
|
||||
name="regions"
|
||||
class="form-input"
|
||||
rows="6"
|
||||
placeholder={'[\n { "name": "main", "row": 0, "col": 0, "rowSpan": 12, "colSpan": 12 }\n]'}
|
||||
>{v["regions"] ?? ""}</textarea>
|
||||
<div class="form-hint">
|
||||
Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create Template</button>
|
||||
<a href="/admin/templates" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Template Edit ----------------------------------------------------------
|
||||
|
||||
interface TemplateEditPageProps {
|
||||
user: string;
|
||||
template: LayoutTemplate;
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
export function TemplateEditPage(props: TemplateEditPageProps) {
|
||||
const t = props.template;
|
||||
return (
|
||||
<Layout
|
||||
title={`Template: ${t.name}`}
|
||||
user={props.user}
|
||||
activeNav="templates"
|
||||
flash={
|
||||
props.error ? { type: "error", message: props.error }
|
||||
: props.success ? { type: "success", message: props.success }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div style="max-width:700px">
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Template</h2>
|
||||
<form method="post" action={`/admin/templates/${t.id}`}>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" class="form-input" value={t.name} required maxlength="128" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<input id="description" name="description" type="text" class="form-input" value={t.description ?? ""} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="/admin/templates" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid: {String(t.grid_cols)}x{String(t.grid_rows)}</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Position</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.regions.length === 0 ? (
|
||||
<tr><td colspan="3" style="text-align:center; color:#999; padding:1rem">No regions defined</td></tr>
|
||||
) : (
|
||||
t.regions.map((r) => (
|
||||
<tr>
|
||||
<td><strong>{r.name}</strong></td>
|
||||
<td>row {String(r.row)}, col {String(r.col)}</td>
|
||||
<td>{String(r.rowSpan)}x{String(r.colSpan)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual grid preview */}
|
||||
{t.regions.length > 0 && (
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Preview</h2>
|
||||
<div style={`display:grid; grid-template-columns:repeat(${String(t.grid_cols)}, 1fr); grid-template-rows:repeat(${String(t.grid_rows)}, 30px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
|
||||
{t.regions.map((r) => (
|
||||
<div style={`grid-column:${String(r.col + 1)} / span ${String(r.colSpan)}; grid-row:${String(r.row + 1)} / span ${String(r.rowSpan)}; background:#dbeafe; display:flex; align-items:center; justify-content:center; font-size:0.75rem; font-weight:600; color:#1e40af; border-radius:2px`}>
|
||||
{r.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!t.is_builtin && (
|
||||
<form method="post" action={`/admin/templates/${t.id}/delete`} style="margin-top:1rem">
|
||||
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this template? Layouts using it will break.')"}}>Delete Template</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Layouts ----------------------------------------------------------------
|
||||
|
||||
interface LayoutsPageProps {
|
||||
user: string;
|
||||
layouts: LayoutType[];
|
||||
templates: Map<number, LayoutTemplate>;
|
||||
displays: Map<number, Display>;
|
||||
}
|
||||
|
||||
|
|
@ -1108,14 +890,14 @@ export function LayoutsPage(props: LayoutsPageProps) {
|
|||
<a href="/admin/layouts/new" class="btn btn-primary">New Layout</a>
|
||||
</div>
|
||||
<p style="color:#666; margin-bottom:1.25rem">
|
||||
A layout binds cameras and other content into a template's regions for one display.
|
||||
A layout defines a grid of regions and binds cameras or other content into them for a display.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Template</th>
|
||||
<th>Grid</th>
|
||||
<th>Display</th>
|
||||
<th>Priority</th>
|
||||
<th>Default</th>
|
||||
|
|
@ -1126,12 +908,11 @@ export function LayoutsPage(props: LayoutsPageProps) {
|
|||
<tr><td colspan="5" style="text-align:center; color:#999; padding:2rem">No layouts created yet</td></tr>
|
||||
) : (
|
||||
props.layouts.map((l) => {
|
||||
const tmpl = props.templates.get(l.template_id);
|
||||
const disp = props.displays.get(l.display_id);
|
||||
return (
|
||||
<tr>
|
||||
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
||||
<td>{tmpl ? tmpl.name : `#${String(l.template_id)}`}</td>
|
||||
<td>{String(l.grid_cols)}x{String(l.grid_rows)} ({String(l.regions.length)} regions)</td>
|
||||
<td>{disp ? disp.name : `#${String(l.display_id)}`}</td>
|
||||
<td>
|
||||
{l.priority === "hot"
|
||||
|
|
@ -1159,7 +940,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
|
|||
|
||||
interface LayoutNewPageProps {
|
||||
user: string;
|
||||
templates: LayoutTemplate[];
|
||||
displays: Display[];
|
||||
error?: string;
|
||||
values?: Record<string, string>;
|
||||
|
|
@ -1174,73 +954,119 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
|||
activeNav="layouts"
|
||||
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||
>
|
||||
<div style="max-width:600px">
|
||||
<form method="post" action="/admin/layouts/new">
|
||||
<div class="form-group">
|
||||
<label for="name">Layout Name</label>
|
||||
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||
<div style="max-width:700px">
|
||||
{/* Quick presets */}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Quick Create from Preset</h2>
|
||||
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
||||
Pick a preset grid layout. You can also define a custom grid below.
|
||||
</p>
|
||||
<div class="stats-grid" style="margin-bottom:0">
|
||||
{[
|
||||
{ preset: "fullscreen", label: "Fullscreen", desc: "1x1 grid, single region" },
|
||||
{ preset: "2x2", label: "2x2 Grid", desc: "4 equal regions" },
|
||||
{ preset: "1plus3", label: "1+3", desc: "Large left, 3 stacked right" },
|
||||
{ preset: "3x3", label: "3x3 Grid", desc: "9 equal regions" },
|
||||
].map((p) => (
|
||||
<form method="post" action="/admin/layouts/new" style="margin:0">
|
||||
<input type="hidden" name="preset" value={p.preset} />
|
||||
<input type="hidden" name="name" value={v["name"] || p.label} />
|
||||
<input type="hidden" name="display_id" value={v["display_id"] ?? String(props.displays[0]?.id ?? "")} />
|
||||
<input type="hidden" name="is_default" value={v["is_default"] ?? "0"} />
|
||||
<input type="hidden" name="resets_idle_timer" value={v["resets_idle_timer"] ?? "1"} />
|
||||
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||
<strong>{p.label}</strong>
|
||||
<div style="color:#666; font-size:0.8rem">{p.desc}</div>
|
||||
</button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template_id">Template</label>
|
||||
<select id="template_id" name="template_id" class="form-input" required>
|
||||
<option value="">-- Select Template --</option>
|
||||
{props.templates.map((t) => (
|
||||
<option value={String(t.id)} selected={v["template_id"] === String(t.id)}>
|
||||
{t.name} ({String(t.grid_cols)}x{String(t.grid_rows)}, {String(t.regions.length)} regions)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{props.templates.length === 0 && (
|
||||
<div class="form-hint">
|
||||
No templates exist. <a href="/admin/templates/new">Create one first</a>.
|
||||
{/* Full form */}
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Custom Layout</h2>
|
||||
<form method="post" action="/admin/layouts/new">
|
||||
<input type="hidden" name="preset" value="custom" />
|
||||
<div class="form-group">
|
||||
<label for="name">Layout Name</label>
|
||||
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="display_id">Display</label>
|
||||
<select id="display_id" name="display_id" class="form-input" required>
|
||||
<option value="">-- Select Display --</option>
|
||||
{props.displays.map((d) => (
|
||||
<option value={String(d.id)} selected={v["display_id"] === String(d.id)}>
|
||||
{d.name} ({String(d.width_px)}x{String(d.height_px)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{props.displays.length === 0 && (
|
||||
<div class="form-hint">
|
||||
No displays exist yet. Pair a kiosk first to create a display.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
||||
<div class="form-group">
|
||||
<label for="grid_cols">Grid Columns</label>
|
||||
<input id="grid_cols" name="grid_cols" type="number" class="form-input" min="1" max="12" value={v["grid_cols"] ?? "1"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grid_rows">Grid Rows</label>
|
||||
<input id="grid_rows" name="grid_rows" type="number" class="form-input" min="1" max="12" value={v["grid_rows"] ?? "1"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="display_id">Display</label>
|
||||
<select id="display_id" name="display_id" class="form-input" required>
|
||||
<option value="">-- Select Display --</option>
|
||||
{props.displays.map((d) => (
|
||||
<option value={String(d.id)} selected={v["display_id"] === String(d.id)}>
|
||||
{d.name} ({String(d.width_px)}x{String(d.height_px)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="regions">Regions (JSON)</label>
|
||||
<textarea
|
||||
id="regions"
|
||||
name="regions"
|
||||
class="form-input"
|
||||
rows="6"
|
||||
placeholder={'[\n { "name": "main", "row": 0, "col": 0, "rowSpan": 1, "colSpan": 1 }\n]'}
|
||||
>{v["regions"] ?? ""}</textarea>
|
||||
<div class="form-hint">
|
||||
Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="priority">Priority</label>
|
||||
<select id="priority" name="priority" class="form-input">
|
||||
<option value="normal" selected={v["priority"] !== "hot" && v["priority"] !== "cold"}>Normal</option>
|
||||
<option value="hot" selected={v["priority"] === "hot"}>Hot (always warm)</option>
|
||||
<option value="cold" selected={v["priority"] === "cold"}>Cold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="priority">Priority</label>
|
||||
<select id="priority" name="priority" class="form-input">
|
||||
<option value="normal" selected={v["priority"] !== "hot" && v["priority"] !== "cold"}>Normal</option>
|
||||
<option value="hot" selected={v["priority"] === "hot"}>Hot (always warm)</option>
|
||||
<option value="cold" selected={v["priority"] === "cold"}>Cold</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_default" value="1" checked={v["is_default"] === "1"} />
|
||||
{" "}Set as default layout for this display
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_default" value="1" checked={v["is_default"] === "1"} />
|
||||
{" "}Set as default layout for this display
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="resets_idle_timer" value="1" checked={v["resets_idle_timer"] !== "0"} />
|
||||
{" "}Resets idle timer when activated
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="resets_idle_timer" value="1" checked={v["resets_idle_timer"] !== "0"} />
|
||||
{" "}Resets idle timer when activated
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create Layout</button>
|
||||
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-primary">Create Layout</button>
|
||||
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
@ -1251,7 +1077,6 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
|||
interface LayoutEditPageProps {
|
||||
user: string;
|
||||
layout: LayoutType;
|
||||
template: LayoutTemplate;
|
||||
display: Display;
|
||||
cells: LayoutCell[];
|
||||
cameras: Camera[];
|
||||
|
|
@ -1261,7 +1086,6 @@ interface LayoutEditPageProps {
|
|||
|
||||
export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||
const l = props.layout;
|
||||
const t = props.template;
|
||||
// Build a map from region_name → cell for easy lookup
|
||||
const cellByRegion = new Map<string, LayoutCell>();
|
||||
for (const c of props.cells) {
|
||||
|
|
@ -1326,17 +1150,17 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
|||
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||
</form>
|
||||
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
|
||||
<div>Template: <a href={`/admin/templates/${t.id}`}>{t.name}</a> ({String(t.grid_cols)}x{String(t.grid_rows)})</div>
|
||||
<div>Grid: {String(l.grid_cols)}x{String(l.grid_rows)}, {String(l.regions.length)} region{l.regions.length !== 1 ? "s" : ""}</div>
|
||||
<div>Display: <a href={`/admin/displays/${props.display.id}`}>{props.display.name}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template preview with cell assignments */}
|
||||
{t.regions.length > 0 && (
|
||||
{/* Grid preview with cell assignments */}
|
||||
{l.regions.length > 0 && (
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Grid Preview</h2>
|
||||
<div style={`display:grid; grid-template-columns:repeat(${String(t.grid_cols)}, 1fr); grid-template-rows:repeat(${String(t.grid_rows)}, 40px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
|
||||
{t.regions.map((r) => {
|
||||
<div style={`display:grid; grid-template-columns:repeat(${String(l.grid_cols)}, 1fr); grid-template-rows:repeat(${String(l.grid_rows)}, 40px); gap:2px; background:#e5e7eb; padding:2px; border-radius:4px`}>
|
||||
{l.regions.map((r) => {
|
||||
const cell = cellByRegion.get(r.name);
|
||||
let label = r.name;
|
||||
let bgColor = "#f9fafb";
|
||||
|
|
@ -1363,6 +1187,35 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Regions table */}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Regions</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Position</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{l.regions.length === 0 ? (
|
||||
<tr><td colspan="3" style="text-align:center; color:#999; padding:1rem">No regions defined</td></tr>
|
||||
) : (
|
||||
l.regions.map((r) => (
|
||||
<tr>
|
||||
<td><strong>{r.name}</strong></td>
|
||||
<td>row {String(r.row)}, col {String(r.col)}</td>
|
||||
<td>{String(r.rowSpan)}x{String(r.colSpan)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cell assignments table */}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Cell Assignments</h2>
|
||||
|
|
@ -1376,7 +1229,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.regions.map((r) => {
|
||||
{l.regions.map((r) => {
|
||||
const cell = cellByRegion.get(r.name);
|
||||
return (
|
||||
<tr>
|
||||
|
|
@ -1422,7 +1275,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
|||
<label for="region_name">Region</label>
|
||||
<select id="region_name" name="region_name" class="form-input" required>
|
||||
<option value="">-- Select Region --</option>
|
||||
{t.regions.map((r) => {
|
||||
{l.regions.map((r) => {
|
||||
const taken = cellByRegion.has(r.name);
|
||||
return (
|
||||
<option value={r.name} disabled={taken}>
|
||||
|
|
@ -1507,6 +1360,7 @@ interface DisplayEditPageProps {
|
|||
user: string;
|
||||
display: Display;
|
||||
layouts: LayoutType[];
|
||||
kioskName?: string | null;
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
|
@ -1530,7 +1384,9 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
|
|||
<div style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
||||
<div>Index: {String(d.index)}</div>
|
||||
<div>Resolution: {String(d.width_px)}x{String(d.height_px)}</div>
|
||||
<div>Primary: {d.is_primary ? "Yes" : "No"}</div>
|
||||
{d.kiosk_id && (
|
||||
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
|
||||
)}
|
||||
</div>
|
||||
<form method="post" action={`/admin/displays/${d.id}`}>
|
||||
<div class="form-group">
|
||||
|
|
@ -1618,7 +1474,7 @@ interface DisplaysPageProps {
|
|||
export function DisplaysPage(props: DisplaysPageProps) {
|
||||
return (
|
||||
<Layout title="Displays" user={props.user} activeNav="displays">
|
||||
<p style="color:#666; margin-bottom:1.25rem">Physical HDMI displays. Primary display created during setup.</p>
|
||||
<p style="color:#666; margin-bottom:1.25rem">Physical HDMI displays. Created automatically when kiosks are paired.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
||||
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
||||
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
||||
<NavItem href="/admin/templates" label="Templates" icon="▩" active={a === "templates"} />
|
||||
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue