From 026325ccd058d8c828d3a6870f51c1ce238529d9 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Mon, 11 May 2026 09:38:50 +0200 Subject: [PATCH] feat(layout): add branded none cells Migrate empty layout cells to an explicit none state so kiosk renders the BetterFrame placeholder instead of blank HTML. --- kiosk/src/ui.rs | 110 +++++++++++++----- server/package.json | 2 +- .../src/plugins/service-store/migrations.ts | 56 ++++++++- .../src/plugins/service-store/repository.ts | 4 +- server/src/schemas/wire/bundle.ts | 2 +- server/src/scripts/copy-web-static.ts | 19 +++ server/src/shared/types.ts | 2 +- server/src/web-static/betterframe-logo.svg | 24 ++++ server/src/web-static/betterframe-mark.svg | 22 ++++ server/src/web-templates/admin-pages.tsx | 4 +- server/src/web-templates/layout.tsx | 14 ++- 11 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 server/src/scripts/copy-web-static.ts create mode 100644 server/src/web-static/betterframe-logo.svg create mode 100644 server/src/web-static/betterframe-mark.svg diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index dfa6fda..7aa4f1b 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -20,6 +20,8 @@ use crate::ws_client; use crate::ServerMsg; const APP_ID: &str = "dev.betterframe.kiosk"; +const BETTERFRAME_LOGO_SVG: &str = include_str!("../../server/src/web-static/betterframe-logo.svg"); +const BETTERFRAME_MARK_SVG: &str = include_str!("../../server/src/web-static/betterframe-mark.svg"); pub fn build_app() -> Application { let app = Application::builder().application_id(APP_ID).build(); @@ -156,9 +158,7 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) { vbox.set_halign(gtk::Align::Center); vbox.set_vexpand(true); - let title = Label::new(Some("BetterFrame")); - add_css(&title, ".title { font-size: 24px; color: #888; font-weight: 300; }"); - title.add_css_class("title"); + let title = logo_picture(BETTERFRAME_LOGO_SVG, 360, 88, "pairing-logo"); let code_label = Label::new(Some(code)); add_css(&code_label, ".code { font-size: 72px; color: #fff; font-weight: 700; letter-spacing: 12px; font-family: monospace; }"); @@ -247,32 +247,41 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { picture.set_hexpand(true); picture.upcast() } else { - placeholder(&format!("{} (no stream)", cam.name)) + placeholder(Some(&format!("{} (no stream)", cam.name))) } } else { - placeholder("Unknown camera") + placeholder(Some("Unknown camera")) } } else { - placeholder("No camera assigned") + none_cell() } } "html" => { let html = cell.html_content.as_deref().unwrap_or(""); - let webview = webkit6::WebView::new(); - webkit6::prelude::WebViewExt::load_html(&webview, html, None); - webview.set_vexpand(true); - webview.set_hexpand(true); - webview.upcast() + if html.trim().is_empty() { + none_cell() + } else { + let webview = webkit6::WebView::new(); + webkit6::prelude::WebViewExt::load_html(&webview, html, None); + webview.set_vexpand(true); + webview.set_hexpand(true); + webview.upcast() + } } "web" => { - let url = cell.web_url.as_deref().unwrap_or("about:blank"); - let webview = webkit6::WebView::new(); - webkit6::prelude::WebViewExt::load_uri(&webview, url); - webview.set_vexpand(true); - webview.set_hexpand(true); - webview.upcast() + let url = cell.web_url.as_deref().unwrap_or("").trim(); + if url.is_empty() { + none_cell() + } else { + let webview = webkit6::WebView::new(); + webkit6::prelude::WebViewExt::load_uri(&webview, url); + webview.set_vexpand(true); + webview.set_hexpand(true); + webview.upcast() + } } - _ => placeholder("Unknown content"), + "none" => none_cell(), + _ => placeholder(Some("Unknown content")), }; grid.attach( @@ -309,20 +318,61 @@ fn ensure_warm( } fn show_logo(window: &ApplicationWindow) { - let label = Label::new(Some("BetterFrame")); - add_css(&label, "label { font-size: 48px; color: #fff; font-weight: 300; }"); - label.set_valign(gtk::Align::Center); - label.set_halign(gtk::Align::Center); - label.set_vexpand(true); - window.set_child(Some(&label)); + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.set_valign(gtk::Align::Center); + vbox.set_halign(gtk::Align::Center); + vbox.set_vexpand(true); + vbox.set_hexpand(true); + vbox.append(&logo_picture(BETTERFRAME_LOGO_SVG, 480, 118, "idle-logo")); + window.set_child(Some(&vbox)); } -fn placeholder(text: &str) -> gtk::Widget { - let label = Label::new(Some(text)); - add_css(&label, "label { color: #666; font-size: 14px; background-color: #111; }"); - label.set_vexpand(true); - label.set_hexpand(true); - label.upcast() +fn none_cell() -> gtk::Widget { + placeholder(None) +} + +fn placeholder(text: Option<&str>) -> gtk::Widget { + let vbox = GtkBox::new(Orientation::Vertical, 8); + add_css( + &vbox, + ".bf-placeholder { background-color: #111; } .bf-placeholder-text { color: #666; font-size: 14px; }", + ); + vbox.add_css_class("bf-placeholder"); + vbox.set_valign(gtk::Align::Center); + vbox.set_halign(gtk::Align::Center); + vbox.set_vexpand(true); + vbox.set_hexpand(true); + vbox.append(&logo_picture(BETTERFRAME_MARK_SVG, 56, 56, "cell-logo")); + if let Some(text) = text { + let label = Label::new(Some(text)); + label.add_css_class("bf-placeholder-text"); + vbox.append(&label); + } + vbox.upcast() +} + +fn logo_picture(svg: &'static str, width: i32, height: i32, css_class: &str) -> gtk::Widget { + let bytes = gtk::glib::Bytes::from_static(svg.as_bytes()); + match gtk::gdk::Texture::from_bytes(&bytes) { + Ok(texture) => { + let picture = Picture::for_paintable(&texture); + picture.add_css_class(css_class); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_can_shrink(true); + picture.set_size_request(width, height); + picture.set_valign(gtk::Align::Center); + picture.set_halign(gtk::Align::Center); + picture.upcast() + } + Err(err) => { + warn!("failed to load embedded logo: {err}"); + let label = Label::new(Some("BetterFrame")); + label.set_size_request(width, height); + label.set_valign(gtk::Align::Center); + label.set_halign(gtk::Align::Center); + label.upcast() + } + } } fn add_css(widget: &impl IsA, css: &str) { diff --git a/server/package.json b/server/package.json index c64f2c4..3624ac8 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,7 @@ "bsb-tests.json" ], "scripts": { - "build": "cross-env NODE_OPTIONS=\"--import tsx\" bsb-plugin-cli build", + "build": "cross-env NODE_OPTIONS=\"--import tsx\" bsb-plugin-cli build && cross-env NODE_OPTIONS=\"--import tsx\" node src/scripts/copy-web-static.ts", "clean": "bsb-plugin-cli clean", "start": "bsb-plugin-cli start", "dev": "cross-env NODE_OPTIONS=\"--import tsx\" bsb-plugin-cli dev", diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index fd789b2..3d9ebe1 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -178,7 +178,7 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ id INTEGER PRIMARY KEY AUTOINCREMENT, layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, region_name TEXT NOT NULL, - content_type TEXT NOT NULL CHECK(content_type IN ('camera', 'web', 'html')), + content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, stream_selector TEXT NOT NULL DEFAULT 'auto' CHECK(stream_selector IN ('auto', 'main', 'sub')), @@ -421,7 +421,7 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ col INTEGER NOT NULL DEFAULT 0, row_span INTEGER NOT NULL DEFAULT 1, col_span INTEGER NOT NULL DEFAULT 1, - content_type TEXT NOT NULL CHECK(content_type IN ('camera', 'web', 'html')), + content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, stream_selector TEXT, web_url TEXT, @@ -537,4 +537,56 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ // empty cell — leave entity_id null } }, + + // ---- v0.9: explicit empty layout cell state ------------------------------- + (db: DatabaseSync) => { + const createSql = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'layout_cells'") + .get() as { sql?: string } | undefined; + if (createSql?.sql?.includes("'none'")) return; + + db.exec("PRAGMA foreign_keys = OFF"); + db.exec(` + CREATE TABLE layout_cells_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, + row INTEGER NOT NULL DEFAULT 0, + col INTEGER NOT NULL DEFAULT 0, + row_span INTEGER NOT NULL DEFAULT 1, + col_span INTEGER NOT NULL DEFAULT 1, + content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), + camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, + stream_selector TEXT, + web_url TEXT, + html_content TEXT, + cooling_timeout_seconds INTEGER, + options TEXT NOT NULL DEFAULT '{}', + entity_id INTEGER REFERENCES entities(id) ON DELETE SET NULL + ) STRICT; + + INSERT INTO layout_cells_new ( + id, layout_id, row, col, row_span, col_span, + content_type, camera_id, stream_selector, web_url, html_content, + cooling_timeout_seconds, options, entity_id + ) + SELECT + id, layout_id, row, col, row_span, col_span, + CASE + WHEN entity_id IS NULL + AND content_type = 'html' + AND (html_content IS NULL OR html_content = '') + THEN 'none' + ELSE content_type + END, + camera_id, stream_selector, web_url, html_content, + cooling_timeout_seconds, options, entity_id + FROM layout_cells; + + DROP TABLE layout_cells; + ALTER TABLE layout_cells_new RENAME TO layout_cells; + CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id); + CREATE INDEX IF NOT EXISTS idx_layout_cells_entity ON layout_cells(entity_id); + `); + db.exec("PRAGMA foreign_keys = ON"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 97e7a9b..ee99fda 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -559,7 +559,7 @@ export class Repository { }): LayoutCell { // Resolve content fields from the entity (if given). The legacy columns // remain populated for backward-compatible bundle generation. - let contentType = input.content_type ?? "html"; + let contentType = input.content_type ?? "none"; let cameraId: number | null = input.camera_id ?? null; let webUrl: string | null = input.web_url ?? null; let htmlContent: string | null = input.html_content ?? null; @@ -609,7 +609,7 @@ export class Repository { .prepare( `UPDATE layout_cells SET entity_id = NULL, - content_type = 'html', + content_type = 'none', camera_id = NULL, web_url = NULL, html_content = NULL diff --git a/server/src/schemas/wire/bundle.ts b/server/src/schemas/wire/bundle.ts index ca3535f..11078cb 100644 --- a/server/src/schemas/wire/bundle.ts +++ b/server/src/schemas/wire/bundle.ts @@ -15,7 +15,7 @@ const cameraType = av.enum_(["rtsp", "onvif"] as const); const streamRole = av.enum_(["main", "sub", "other"] as const); const streamSelector = av.enum_(["auto", "main", "sub"] as const); const layoutPriority = av.enum_(["hot", "normal", "cold"] as const); -const cellContentType = av.enum_(["camera", "web", "html"] as const); +const cellContentType = av.enum_(["none", "camera", "web", "html"] as const); const cameraStream = av.object( { diff --git a/server/src/scripts/copy-web-static.ts b/server/src/scripts/copy-web-static.ts new file mode 100644 index 0000000..508b40e --- /dev/null +++ b/server/src/scripts/copy-web-static.ts @@ -0,0 +1,19 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SERVER_ROOT = join(__dirname, "..", ".."); +const SRC_STATIC_DIR = join(SERVER_ROOT, "src", "web-static"); +const LIB_STATIC_DIR = join(SERVER_ROOT, "lib", "web-static"); + +if (!existsSync(SRC_STATIC_DIR)) { + throw new Error(`Static asset source not found: ${SRC_STATIC_DIR}`); +} + +rmSync(LIB_STATIC_DIR, { recursive: true, force: true }); +mkdirSync(LIB_STATIC_DIR, { recursive: true }); +cpSync(SRC_STATIC_DIR, LIB_STATIC_DIR, { recursive: true }); + +// eslint-disable-next-line no-console +console.log(`Copied web-static assets to ${LIB_STATIC_DIR}`); diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index b616dbc..02a5370 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -12,7 +12,7 @@ export type StreamRole = "main" | "sub" | "other"; export type StreamSelector = "auto" | "main" | "sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub"; export type LayoutPriority = "hot" | "normal" | "cold"; -export type CellContentType = "camera" | "web" | "html"; +export type CellContentType = "none" | "camera" | "web" | "html"; export type EntityType = "camera" | "html" | "web"; export interface Entity { diff --git a/server/src/web-static/betterframe-logo.svg b/server/src/web-static/betterframe-logo.svg new file mode 100644 index 0000000..59f03a6 --- /dev/null +++ b/server/src/web-static/betterframe-logo.svg @@ -0,0 +1,24 @@ + + BetterFrame logo + BetterFrame wordmark with a display frame and multi-camera grid icon. + + + + + + + + + + + + + + + + + + + BetterFrame + MULTI-CAMERA DISPLAY + diff --git a/server/src/web-static/betterframe-mark.svg b/server/src/web-static/betterframe-mark.svg new file mode 100644 index 0000000..fea1ba6 --- /dev/null +++ b/server/src/web-static/betterframe-mark.svg @@ -0,0 +1,22 @@ + + BetterFrame mark + A display frame with a multi-camera grid and highlighted active frame. + + + + + + + + + + + + + + + + + + + diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 5303f92..d582748 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1517,6 +1517,7 @@ function cellLabel( if (c.content_type === "camera" && c.camera_id) { return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`; } + if (c.content_type === "none") return "None"; if (c.content_type === "web") return c.web_url ? `Web: ${c.web_url}` : "Web"; if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)"; return "Empty"; @@ -1619,7 +1620,8 @@ export function renderCell( // Read mode. Empty when no entity is bound. const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null; const isEmpty = !ent && ( - (c.content_type === "html" && !c.html_content) + c.content_type === "none" + || (c.content_type === "html" && !c.html_content) || (c.content_type === "camera" && !c.camera_id) || (c.content_type === "web" && !c.web_url) ); diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 8c9e95c..5e9aaae 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -38,6 +38,7 @@ function Sidebar(props: { activeNav?: string }) { return (