mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +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 struct BundleLayout {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub template: Option<BundleTemplate>,
|
pub regions: Vec<BundleRegion>,
|
||||||
|
pub grid_cols: u32,
|
||||||
|
pub grid_rows: u32,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
pub cooling_timeout_seconds: Option<u32>,
|
pub cooling_timeout_seconds: Option<u32>,
|
||||||
pub preload_camera_ids: Vec<u32>,
|
pub preload_camera_ids: Vec<u32>,
|
||||||
|
|
@ -34,15 +36,6 @@ pub struct BundleLayout {
|
||||||
pub cells: Vec<BundleCell>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleRegion {
|
pub struct BundleRegion {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
|
||||||
|
|
@ -120,14 +120,14 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(ref template) = layout.template else {
|
if layout.regions.is_empty() {
|
||||||
warn!("layout has no template");
|
warn!("layout has no regions");
|
||||||
show_logo(window);
|
show_logo(window);
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
info!("rendering layout '{}' with {}x{} grid, {} cells",
|
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();
|
let grid = Grid::new();
|
||||||
grid.set_row_homogeneous(true);
|
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()));
|
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 = 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 {
|
let Some(region) = region else {
|
||||||
warn!("region '{}' not found in template", cell.region_name);
|
warn!("region '{}' not found in layout", cell.region_name);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -200,7 +200,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill empty regions
|
// Fill empty regions
|
||||||
for region in &template.regions {
|
for region in &layout.regions {
|
||||||
if !layout.cells.iter().any(|c| c.region_name == region.name) {
|
if !layout.cells.iter().any(|c| c.region_name == region.name) {
|
||||||
let empty = GtkBox::new(Orientation::Vertical, 0);
|
let empty = GtkBox::new(Orientation::Vertical, 0);
|
||||||
add_css(&empty, "box { background-color: #111; }");
|
add_css(&empty, "box { background-color: #111; }");
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,13 @@ import {
|
||||||
KiosksPage,
|
KiosksPage,
|
||||||
KioskEditPage,
|
KioskEditPage,
|
||||||
LabelsPage,
|
LabelsPage,
|
||||||
TemplatesPage,
|
|
||||||
TemplateNewPage,
|
|
||||||
TemplateEditPage,
|
|
||||||
LayoutsPage,
|
LayoutsPage,
|
||||||
LayoutNewPage,
|
LayoutNewPage,
|
||||||
LayoutEditPage,
|
LayoutEditPage,
|
||||||
DisplaysPage,
|
DisplaysPage,
|
||||||
DisplayEditPage,
|
DisplayEditPage,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} 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 {
|
function sanitizeRtspUrl(raw: string): string {
|
||||||
const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/);
|
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" } });
|
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 user = event.context.user!;
|
||||||
const templates = deps.repo.listLayoutTemplates();
|
const layouts = deps.repo.listLayouts();
|
||||||
return htmlPage(TemplatesPage({ user: user.username, templates }));
|
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!;
|
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 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 preset = body?.["preset"] ?? "custom";
|
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[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!name || name.length > 128) {
|
if (!name || name.length > 128) errors.push("Name required (max 128 chars).");
|
||||||
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 }> = [];
|
type Region = { name: string; row: number; col: number; rowSpan: number; colSpan: number };
|
||||||
let gridCols = 12;
|
let regions: Region[] = [];
|
||||||
let gridRows = 12;
|
let gridCols = 1;
|
||||||
|
let gridRows = 1;
|
||||||
|
|
||||||
if (preset === "fullscreen") {
|
if (preset === "fullscreen") {
|
||||||
gridCols = 1;
|
gridCols = 1;
|
||||||
|
|
@ -262,14 +273,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// Custom
|
// Custom
|
||||||
gridCols = parseInt(body?.["grid_cols"] ?? "12", 10);
|
gridCols = parseInt(body?.["grid_cols"] ?? "1", 10);
|
||||||
gridRows = parseInt(body?.["grid_rows"] ?? "12", 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(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.");
|
if (isNaN(gridRows) || gridRows < 1 || gridRows > 12) errors.push("Grid rows must be 1-12.");
|
||||||
|
|
||||||
const regionsStr = (body?.["regions"] ?? "").trim();
|
const regionsStr = (body?.["regions"] ?? "").trim();
|
||||||
if (!regionsStr) {
|
if (!regionsStr) {
|
||||||
errors.push("Regions JSON is required for custom templates.");
|
errors.push("Regions JSON is required for custom layout.");
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
regions = JSON.parse(regionsStr);
|
regions = JSON.parse(regionsStr);
|
||||||
|
|
@ -283,96 +294,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
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({
|
return htmlPage(LayoutNewPage({
|
||||||
user: user.username,
|
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(),
|
displays: deps.repo.listDisplays(),
|
||||||
error: errors.join(" "),
|
error: errors.join(" "),
|
||||||
values: body,
|
values: body,
|
||||||
|
|
@ -382,7 +305,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const layout = deps.repo.createLayout({
|
const layout = deps.repo.createLayout({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
template_id: templateId,
|
regions,
|
||||||
|
grid_cols: gridCols,
|
||||||
|
grid_rows: gridRows,
|
||||||
display_id: displayId,
|
display_id: displayId,
|
||||||
priority,
|
priority,
|
||||||
is_default: isDefault,
|
is_default: isDefault,
|
||||||
|
|
@ -397,8 +322,6 @@ 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 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);
|
const display = deps.repo.getDisplayById(layout.display_id);
|
||||||
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
const cells = deps.repo.layoutCells(id);
|
const cells = deps.repo.layoutCells(id);
|
||||||
|
|
@ -406,7 +329,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return htmlPage(LayoutEditPage({
|
return htmlPage(LayoutEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
layout,
|
layout,
|
||||||
template,
|
|
||||||
display,
|
display,
|
||||||
cells,
|
cells,
|
||||||
cameras,
|
cameras,
|
||||||
|
|
@ -479,7 +401,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
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 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) => {
|
app.post("/admin/displays/:id", async (event) => {
|
||||||
|
|
@ -635,11 +558,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
name: kl.name,
|
name: kl.name,
|
||||||
role: kl.role,
|
role: kl.role,
|
||||||
}));
|
}));
|
||||||
|
const displays = deps.repo.listDisplaysForKiosk(id);
|
||||||
return htmlPage(KioskEditPage({
|
return htmlPage(KioskEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
kiosk,
|
kiosk,
|
||||||
labels: kioskLabels,
|
labels: kioskLabels,
|
||||||
allLabels: deps.repo.listLabels(),
|
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.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
||||||
deps.repo.markClusterKeyProvisioned();
|
deps.repo.markClusterKeyProvisioned();
|
||||||
|
|
||||||
// Create default display, template, and layout
|
// Setup only creates admin user + cluster key.
|
||||||
const display = deps.repo.createDefaultDisplay();
|
// Displays are created when kiosks are paired (kiosk reports HDMI ports).
|
||||||
const template = deps.repo.createLayoutTemplate({
|
// Layouts are created by admin after pairing.
|
||||||
name: "Fullscreen",
|
|
||||||
description: "Single region covering the entire display",
|
|
||||||
regions: [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }],
|
|
||||||
grid_cols: 1,
|
|
||||||
grid_rows: 1,
|
|
||||||
is_builtin: true,
|
|
||||||
});
|
|
||||||
const layout = deps.repo.createLayout({
|
|
||||||
name: "Default",
|
|
||||||
description: "Default layout — BetterFrame logo",
|
|
||||||
template_id: template.id,
|
|
||||||
display_id: display.id,
|
|
||||||
is_default: true,
|
|
||||||
});
|
|
||||||
deps.repo.createLayoutCell({
|
|
||||||
layout_id: layout.id,
|
|
||||||
region_name: "main",
|
|
||||||
content_type: "html",
|
|
||||||
html_content: '<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#1a1a2e;color:#fff;font-family:system-ui"><h1 style="font-size:3rem;font-weight:300">BetterFrame</h1></div>',
|
|
||||||
});
|
|
||||||
deps.repo.updateDisplay(display.id, { default_layout_id: layout.id });
|
|
||||||
deps.repo.markSetupComplete();
|
deps.repo.markSetupComplete();
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|
|
||||||
|
|
@ -121,8 +121,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
this.db.exec("PRAGMA busy_timeout = 10000");
|
this.db.exec("PRAGMA busy_timeout = 10000");
|
||||||
|
|
||||||
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
|
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
|
||||||
for (const stmt of MIGRATIONS) {
|
for (const entry of MIGRATIONS) {
|
||||||
this.db.exec(stmt);
|
if (typeof entry === "string") {
|
||||||
|
this.db.exec(entry);
|
||||||
|
} else {
|
||||||
|
entry(this.db);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._repo = new Repository(this.db, async (table, op, id) => {
|
this._repo = new Repository(this.db, async (table, op, id) => {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ export function rowToDisplay(r: Row): Display {
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
index: n(r["index"]),
|
index: n(r["index"]),
|
||||||
is_primary: b(r["is_primary"]),
|
is_primary: b(r["is_primary"]),
|
||||||
|
kiosk_id: nn(r["kiosk_id"]),
|
||||||
width_px: n(r["width_px"]),
|
width_px: n(r["width_px"]),
|
||||||
height_px: n(r["height_px"]),
|
height_px: n(r["height_px"]),
|
||||||
default_layout_id: nn(r["default_layout_id"]),
|
default_layout_id: nn(r["default_layout_id"]),
|
||||||
|
|
@ -175,7 +176,10 @@ export function rowToLayout(r: Row): Layout {
|
||||||
id: n(r["id"]),
|
id: n(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
description: sn(r["description"]),
|
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"]),
|
display_id: n(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"]),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,25 @@
|
||||||
* SQLAlchemy's DateTime adapter — we avoid the whole class of issue here.)
|
* 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 ---------------------------------------------------------------
|
// ---- users ---------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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_received ON event_log(received_at DESC)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, 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 {
|
createDefaultDisplay(): Display {
|
||||||
const result = this.prep(
|
const result = this.prep(
|
||||||
`INSERT INTO displays (name, "index", is_primary)
|
`INSERT INTO displays (name, "index", is_primary)
|
||||||
VALUES ('primary', 0, 1)`,
|
VALUES ('primary', 0, 0)`,
|
||||||
).run();
|
).run();
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
void this.notify("displays", "create", id);
|
void this.notify("displays", "create", id);
|
||||||
|
|
@ -359,6 +359,43 @@ export class Repository {
|
||||||
return d;
|
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 {
|
updateDisplay(id: number, patch: Partial<Display>): void {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const vals: unknown[] = [];
|
const vals: unknown[] = [];
|
||||||
|
|
@ -457,7 +494,10 @@ export class Repository {
|
||||||
createLayout(input: {
|
createLayout(input: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
template_id: number;
|
template_id?: number | null;
|
||||||
|
regions: unknown;
|
||||||
|
grid_cols: number;
|
||||||
|
grid_rows: number;
|
||||||
display_id: number;
|
display_id: number;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
cooling_timeout_seconds?: number | null;
|
cooling_timeout_seconds?: number | null;
|
||||||
|
|
@ -466,12 +506,15 @@ export class Repository {
|
||||||
resets_idle_timer?: boolean;
|
resets_idle_timer?: boolean;
|
||||||
}): Layout {
|
}): Layout {
|
||||||
const result = this.prep(
|
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)
|
`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,
|
input.template_id ?? null,
|
||||||
|
J(input.regions),
|
||||||
|
input.grid_cols,
|
||||||
|
input.grid_rows,
|
||||||
input.display_id,
|
input.display_id,
|
||||||
input.priority ?? "normal",
|
input.priority ?? "normal",
|
||||||
input.cooling_timeout_seconds ?? null,
|
input.cooling_timeout_seconds ?? null,
|
||||||
|
|
@ -492,7 +535,7 @@ export class Repository {
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
if (k === "id") continue;
|
if (k === "id") continue;
|
||||||
sets.push(`${k} = ?`);
|
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 if (typeof v === "boolean") vals.push(B(v));
|
||||||
else vals.push(v === undefined ? null : v);
|
else vals.push(v === undefined ? null : v);
|
||||||
}
|
}
|
||||||
|
|
@ -810,19 +853,17 @@ export class Repository {
|
||||||
key_prefix: string;
|
key_prefix: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
hardware_model?: string | null;
|
hardware_model?: string | null;
|
||||||
display_id?: number | null;
|
|
||||||
}): Kiosk {
|
}): Kiosk {
|
||||||
const result = this.prep(
|
const result = this.prep(
|
||||||
`INSERT INTO kiosks
|
`INSERT INTO kiosks
|
||||||
(name, key_hash, key_prefix, capabilities, hardware_model, display_id, paired_at)
|
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.name,
|
input.name,
|
||||||
input.key_hash,
|
input.key_hash,
|
||||||
input.key_prefix,
|
input.key_prefix,
|
||||||
J(input.capabilities ?? []),
|
J(input.capabilities ?? []),
|
||||||
input.hardware_model ?? null,
|
input.hardware_model ?? null,
|
||||||
input.display_id ?? null,
|
|
||||||
isoNow(),
|
isoNow(),
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,7 @@ const camera = av.object(
|
||||||
{ unknownKeys: "reject" },
|
{ 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),
|
name: av.string().minLength(1).maxLength(64),
|
||||||
row: av.int().min(0).max(11),
|
row: av.int().min(0).max(11),
|
||||||
|
|
@ -72,12 +67,6 @@ const layoutTemplate = av.object(
|
||||||
colSpan: av.int().min(1).max(12),
|
colSpan: av.int().min(1).max(12),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "reject" },
|
{ unknownKeys: "reject" },
|
||||||
),
|
|
||||||
),
|
|
||||||
grid_cols: av.int().min(1).max(64),
|
|
||||||
grid_rows: av.int().min(1).max(64),
|
|
||||||
},
|
|
||||||
{ unknownKeys: "reject" },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const layoutCell = av.object(
|
const layoutCell = av.object(
|
||||||
|
|
@ -98,8 +87,9 @@ 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),
|
||||||
template_id: av.int().min(1),
|
regions: av.array(layoutRegion),
|
||||||
display_id: av.int().min(1),
|
grid_cols: 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)),
|
||||||
|
|
@ -117,7 +107,6 @@ export const kioskBundle = av.object(
|
||||||
labels: av.array(av.string()),
|
labels: av.array(av.string()),
|
||||||
operate_labels: av.array(av.string()),
|
operate_labels: av.array(av.string()),
|
||||||
cameras: av.array(camera),
|
cameras: av.array(camera),
|
||||||
templates: av.array(layoutTemplate),
|
|
||||||
layouts: av.array(layout),
|
layouts: av.array(layout),
|
||||||
version: av.string().minLength(1).maxLength(64),
|
version: av.string().minLength(1).maxLength(64),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,11 @@ export interface BundleCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleLayout {
|
export interface BundleLayout {
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
template: {
|
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
regions: unknown;
|
regions: unknown;
|
||||||
grid_cols: number;
|
grid_cols: number;
|
||||||
grid_rows: number;
|
grid_rows: number;
|
||||||
} | null;
|
|
||||||
priority: string;
|
priority: string;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
preload_camera_ids: number[];
|
preload_camera_ids: number[];
|
||||||
|
|
@ -84,9 +80,15 @@ export function generateBundle(
|
||||||
clusterKey: string | undefined,
|
clusterKey: string | undefined,
|
||||||
): KioskBundle | null {
|
): KioskBundle | null {
|
||||||
const kiosk = repo.getKioskById(kioskId);
|
const kiosk = repo.getKioskById(kioskId);
|
||||||
if (!kiosk || !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;
|
if (!display) return null;
|
||||||
|
|
||||||
const layouts = repo.layoutsForDisplayId(display.id);
|
const layouts = repo.layoutsForDisplayId(display.id);
|
||||||
|
|
@ -97,17 +99,12 @@ export function generateBundle(
|
||||||
|
|
||||||
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
||||||
const cells = repo.layoutCells(l.id);
|
const cells = repo.layoutCells(l.id);
|
||||||
const template = l.template_id ? repo.getLayoutTemplateById(l.template_id) : null;
|
|
||||||
return {
|
return {
|
||||||
id: l.id,
|
id: l.id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
template: template ? {
|
regions: l.regions,
|
||||||
id: template.id,
|
grid_cols: l.grid_cols,
|
||||||
name: template.name,
|
grid_rows: l.grid_rows,
|
||||||
regions: template.regions,
|
|
||||||
grid_cols: template.grid_cols,
|
|
||||||
grid_rows: template.grid_rows,
|
|
||||||
} : null,
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -123,17 +123,17 @@ export async function confirmPairing(
|
||||||
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
||||||
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
||||||
|
|
||||||
// Auto-assign to primary display
|
|
||||||
const displays = repo.listDisplays();
|
|
||||||
const primaryDisplay = displays.find((d) => d.is_primary) ?? displays[0];
|
|
||||||
|
|
||||||
const kiosk = repo.createKiosk({
|
const kiosk = repo.createKiosk({
|
||||||
name: kioskName,
|
name: kioskName,
|
||||||
key_hash: kioskKeyHash,
|
key_hash: kioskKeyHash,
|
||||||
key_prefix: kioskKeyPrefix,
|
key_prefix: kioskKeyPrefix,
|
||||||
capabilities: pc.kiosk_capabilities,
|
capabilities: pc.kiosk_capabilities,
|
||||||
hardware_model: pc.kiosk_hardware_model,
|
hardware_model: pc.kiosk_hardware_model,
|
||||||
display_id: primaryDisplay?.id ?? null,
|
});
|
||||||
|
|
||||||
|
// Create a default display for this kiosk (HDMI-0)
|
||||||
|
repo.createDisplayForKiosk(kiosk.id, {
|
||||||
|
name: `${kioskName} HDMI-0`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach initial labels
|
// Attach initial labels
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,8 @@ export interface Display {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
index: number; // unique
|
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;
|
width_px: number;
|
||||||
height_px: number;
|
height_px: number;
|
||||||
default_layout_id: number | null;
|
default_layout_id: number | null;
|
||||||
|
|
@ -139,7 +140,10 @@ export interface Layout {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
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;
|
display_id: number;
|
||||||
priority: LayoutPriority;
|
priority: LayoutPriority;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
|
|
@ -175,7 +179,7 @@ export interface Kiosk {
|
||||||
paired_at: string | null;
|
paired_at: string | null;
|
||||||
last_seen_at: string | null;
|
last_seen_at: string | null;
|
||||||
last_bundle_version: 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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import type {
|
||||||
Layout as LayoutType,
|
Layout as LayoutType,
|
||||||
LayoutCell,
|
LayoutCell,
|
||||||
LayoutRegion,
|
LayoutRegion,
|
||||||
LayoutTemplate,
|
|
||||||
PairingCode,
|
PairingCode,
|
||||||
EventLog,
|
EventLog,
|
||||||
} from "../shared/types.js";
|
} from "../shared/types.js";
|
||||||
|
|
@ -712,6 +711,7 @@ interface KioskEditProps {
|
||||||
kiosk: Kiosk;
|
kiosk: Kiosk;
|
||||||
labels: Array<{ label_id: number; name: string; role: string }>;
|
labels: Array<{ label_id: number; name: string; role: string }>;
|
||||||
allLabels: Label[];
|
allLabels: Label[];
|
||||||
|
displays?: Display[];
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -753,6 +753,29 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||||
{props.labels.length > 0 ? (
|
{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 ----------------------------------------------------------------
|
// ---- Layouts ----------------------------------------------------------------
|
||||||
|
|
||||||
interface LayoutsPageProps {
|
interface LayoutsPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
layouts: LayoutType[];
|
layouts: LayoutType[];
|
||||||
templates: Map<number, LayoutTemplate>;
|
|
||||||
displays: Map<number, Display>;
|
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>
|
<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 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>
|
</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Template</th>
|
<th>Grid</th>
|
||||||
<th>Display</th>
|
<th>Display</th>
|
||||||
<th>Priority</th>
|
<th>Priority</th>
|
||||||
<th>Default</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>
|
<tr><td colspan="5" style="text-align:center; color:#999; padding:2rem">No layouts created yet</td></tr>
|
||||||
) : (
|
) : (
|
||||||
props.layouts.map((l) => {
|
props.layouts.map((l) => {
|
||||||
const tmpl = props.templates.get(l.template_id);
|
|
||||||
const disp = props.displays.get(l.display_id);
|
const disp = props.displays.get(l.display_id);
|
||||||
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>{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>{disp ? disp.name : `#${String(l.display_id)}`}</td>
|
||||||
<td>
|
<td>
|
||||||
{l.priority === "hot"
|
{l.priority === "hot"
|
||||||
|
|
@ -1159,7 +940,6 @@ export function LayoutsPage(props: LayoutsPageProps) {
|
||||||
|
|
||||||
interface LayoutNewPageProps {
|
interface LayoutNewPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
templates: LayoutTemplate[];
|
|
||||||
displays: Display[];
|
displays: Display[];
|
||||||
error?: string;
|
error?: string;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
|
|
@ -1174,30 +954,45 @@ 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:600px">
|
<div style="max-width:700px">
|
||||||
|
{/* Quick presets */}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Quick Create from Preset</h2>
|
||||||
|
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
||||||
|
Pick a preset grid layout. You can also define a custom grid below.
|
||||||
|
</p>
|
||||||
|
<div class="stats-grid" style="margin-bottom:0">
|
||||||
|
{[
|
||||||
|
{ preset: "fullscreen", label: "Fullscreen", desc: "1x1 grid, single region" },
|
||||||
|
{ preset: "2x2", label: "2x2 Grid", desc: "4 equal regions" },
|
||||||
|
{ preset: "1plus3", label: "1+3", desc: "Large left, 3 stacked right" },
|
||||||
|
{ preset: "3x3", label: "3x3 Grid", desc: "9 equal regions" },
|
||||||
|
].map((p) => (
|
||||||
|
<form method="post" action="/admin/layouts/new" style="margin:0">
|
||||||
|
<input type="hidden" name="preset" value={p.preset} />
|
||||||
|
<input type="hidden" name="name" value={v["name"] || p.label} />
|
||||||
|
<input type="hidden" name="display_id" value={v["display_id"] ?? String(props.displays[0]?.id ?? "")} />
|
||||||
|
<input type="hidden" name="is_default" value={v["is_default"] ?? "0"} />
|
||||||
|
<input type="hidden" name="resets_idle_timer" value={v["resets_idle_timer"] ?? "1"} />
|
||||||
|
<button type="submit" class="card" style="width:100%; text-align:left; cursor:pointer; border:1px solid #d0d0d0; background:#fff">
|
||||||
|
<strong>{p.label}</strong>
|
||||||
|
<div style="color:#666; font-size:0.8rem">{p.desc}</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full form */}
|
||||||
|
<div 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">
|
|
||||||
<label for="template_id">Template</label>
|
|
||||||
<select id="template_id" name="template_id" class="form-input" required>
|
|
||||||
<option value="">-- Select Template --</option>
|
|
||||||
{props.templates.map((t) => (
|
|
||||||
<option value={String(t.id)} selected={v["template_id"] === String(t.id)}>
|
|
||||||
{t.name} ({String(t.grid_cols)}x{String(t.grid_rows)}, {String(t.regions.length)} regions)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{props.templates.length === 0 && (
|
|
||||||
<div class="form-hint">
|
|
||||||
No templates exist. <a href="/admin/templates/new">Create one first</a>.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="display_id">Display</label>
|
<label for="display_id">Display</label>
|
||||||
<select id="display_id" name="display_id" class="form-input" required>
|
<select id="display_id" name="display_id" class="form-input" required>
|
||||||
|
|
@ -1208,6 +1003,36 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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">
|
||||||
|
|
@ -1242,6 +1067,7 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
||||||
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1251,7 +1077,6 @@ export function LayoutNewPage(props: LayoutNewPageProps) {
|
||||||
interface LayoutEditPageProps {
|
interface LayoutEditPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
layout: LayoutType;
|
layout: LayoutType;
|
||||||
template: LayoutTemplate;
|
|
||||||
display: Display;
|
display: Display;
|
||||||
cells: LayoutCell[];
|
cells: LayoutCell[];
|
||||||
cameras: Camera[];
|
cameras: Camera[];
|
||||||
|
|
@ -1261,7 +1086,6 @@ interface LayoutEditPageProps {
|
||||||
|
|
||||||
export function LayoutEditPage(props: LayoutEditPageProps) {
|
export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
const l = props.layout;
|
const l = props.layout;
|
||||||
const t = props.template;
|
|
||||||
// Build a map from region_name → cell for easy lookup
|
// Build a map from region_name → cell for easy lookup
|
||||||
const cellByRegion = new Map<string, LayoutCell>();
|
const cellByRegion = new Map<string, LayoutCell>();
|
||||||
for (const c of props.cells) {
|
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>
|
<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>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>Display: <a href={`/admin/displays/${props.display.id}`}>{props.display.name}</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template preview with cell assignments */}
|
{/* Grid preview with cell assignments */}
|
||||||
{t.regions.length > 0 && (
|
{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">Grid Preview</h2>
|
<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`}>
|
<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`}>
|
||||||
{t.regions.map((r) => {
|
{l.regions.map((r) => {
|
||||||
const cell = cellByRegion.get(r.name);
|
const cell = cellByRegion.get(r.name);
|
||||||
let label = r.name;
|
let label = r.name;
|
||||||
let bgColor = "#f9fafb";
|
let bgColor = "#f9fafb";
|
||||||
|
|
@ -1363,6 +1187,35 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
</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 */}
|
{/* Cell assignments table */}
|
||||||
<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">Cell Assignments</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Cell Assignments</h2>
|
||||||
|
|
@ -1376,7 +1229,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{t.regions.map((r) => {
|
{l.regions.map((r) => {
|
||||||
const cell = cellByRegion.get(r.name);
|
const cell = cellByRegion.get(r.name);
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -1422,7 +1275,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
<label for="region_name">Region</label>
|
<label for="region_name">Region</label>
|
||||||
<select id="region_name" name="region_name" class="form-input" required>
|
<select id="region_name" name="region_name" class="form-input" required>
|
||||||
<option value="">-- Select Region --</option>
|
<option value="">-- Select Region --</option>
|
||||||
{t.regions.map((r) => {
|
{l.regions.map((r) => {
|
||||||
const taken = cellByRegion.has(r.name);
|
const taken = cellByRegion.has(r.name);
|
||||||
return (
|
return (
|
||||||
<option value={r.name} disabled={taken}>
|
<option value={r.name} disabled={taken}>
|
||||||
|
|
@ -1507,6 +1360,7 @@ interface DisplayEditPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
display: Display;
|
display: Display;
|
||||||
layouts: LayoutType[];
|
layouts: LayoutType[];
|
||||||
|
kioskName?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1530,7 +1384,9 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
|
||||||
<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)}</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>
|
</div>
|
||||||
<form method="post" action={`/admin/displays/${d.id}`}>
|
<form method="post" action={`/admin/displays/${d.id}`}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -1618,7 +1474,7 @@ interface DisplaysPageProps {
|
||||||
export function DisplaysPage(props: DisplaysPageProps) {
|
export function DisplaysPage(props: DisplaysPageProps) {
|
||||||
return (
|
return (
|
||||||
<Layout title="Displays" user={props.user} activeNav="displays">
|
<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">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
||||||
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
||||||
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
<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/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue