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.
This commit is contained in:
Mitchell R 2026-05-11 09:38:50 +02:00
parent b3c17a9d53
commit 026325ccd0
No known key found for this signature in database
11 changed files with 220 additions and 39 deletions

View file

@ -20,6 +20,8 @@ use crate::ws_client;
use crate::ServerMsg; use crate::ServerMsg;
const APP_ID: &str = "dev.betterframe.kiosk"; 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 { pub fn build_app() -> Application {
let app = Application::builder().application_id(APP_ID).build(); 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_halign(gtk::Align::Center);
vbox.set_vexpand(true); vbox.set_vexpand(true);
let title = Label::new(Some("BetterFrame")); let title = logo_picture(BETTERFRAME_LOGO_SVG, 360, 88, "pairing-logo");
add_css(&title, ".title { font-size: 24px; color: #888; font-weight: 300; }");
title.add_css_class("title");
let code_label = Label::new(Some(code)); 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; }"); 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.set_hexpand(true);
picture.upcast() picture.upcast()
} else { } else {
placeholder(&format!("{} (no stream)", cam.name)) placeholder(Some(&format!("{} (no stream)", cam.name)))
} }
} else { } else {
placeholder("Unknown camera") placeholder(Some("Unknown camera"))
} }
} else { } else {
placeholder("No camera assigned") none_cell()
} }
} }
"html" => { "html" => {
let html = cell.html_content.as_deref().unwrap_or(""); let html = cell.html_content.as_deref().unwrap_or("");
if html.trim().is_empty() {
none_cell()
} else {
let webview = webkit6::WebView::new(); let webview = webkit6::WebView::new();
webkit6::prelude::WebViewExt::load_html(&webview, html, None); webkit6::prelude::WebViewExt::load_html(&webview, html, None);
webview.set_vexpand(true); webview.set_vexpand(true);
webview.set_hexpand(true); webview.set_hexpand(true);
webview.upcast() webview.upcast()
} }
}
"web" => { "web" => {
let url = cell.web_url.as_deref().unwrap_or("about:blank"); let url = cell.web_url.as_deref().unwrap_or("").trim();
if url.is_empty() {
none_cell()
} else {
let webview = webkit6::WebView::new(); let webview = webkit6::WebView::new();
webkit6::prelude::WebViewExt::load_uri(&webview, url); webkit6::prelude::WebViewExt::load_uri(&webview, url);
webview.set_vexpand(true); webview.set_vexpand(true);
webview.set_hexpand(true); webview.set_hexpand(true);
webview.upcast() webview.upcast()
} }
_ => placeholder("Unknown content"), }
"none" => none_cell(),
_ => placeholder(Some("Unknown content")),
}; };
grid.attach( grid.attach(
@ -309,21 +318,62 @@ fn ensure_warm(
} }
fn show_logo(window: &ApplicationWindow) { fn show_logo(window: &ApplicationWindow) {
let label = Label::new(Some("BetterFrame")); let vbox = GtkBox::new(Orientation::Vertical, 0);
add_css(&label, "label { font-size: 48px; color: #fff; font-weight: 300; }"); vbox.set_valign(gtk::Align::Center);
label.set_valign(gtk::Align::Center); vbox.set_halign(gtk::Align::Center);
label.set_halign(gtk::Align::Center); vbox.set_vexpand(true);
label.set_vexpand(true); vbox.set_hexpand(true);
window.set_child(Some(&label)); vbox.append(&logo_picture(BETTERFRAME_LOGO_SVG, 480, 118, "idle-logo"));
window.set_child(Some(&vbox));
} }
fn placeholder(text: &str) -> gtk::Widget { 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)); let label = Label::new(Some(text));
add_css(&label, "label { color: #666; font-size: 14px; background-color: #111; }"); label.add_css_class("bf-placeholder-text");
label.set_vexpand(true); vbox.append(&label);
label.set_hexpand(true); }
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() label.upcast()
} }
}
}
fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) { fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
let provider = gtk::CssProvider::new(); let provider = gtk::CssProvider::new();

View file

@ -12,7 +12,7 @@
"bsb-tests.json" "bsb-tests.json"
], ],
"scripts": { "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", "clean": "bsb-plugin-cli clean",
"start": "bsb-plugin-cli start", "start": "bsb-plugin-cli start",
"dev": "cross-env NODE_OPTIONS=\"--import tsx\" bsb-plugin-cli dev", "dev": "cross-env NODE_OPTIONS=\"--import tsx\" bsb-plugin-cli dev",

View file

@ -178,7 +178,7 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
region_name TEXT NOT NULL, 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, camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT NOT NULL DEFAULT 'auto' stream_selector TEXT NOT NULL DEFAULT 'auto'
CHECK(stream_selector IN ('auto', 'main', 'sub')), CHECK(stream_selector IN ('auto', 'main', 'sub')),
@ -421,7 +421,7 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
col INTEGER NOT NULL DEFAULT 0, col INTEGER NOT NULL DEFAULT 0,
row_span INTEGER NOT NULL DEFAULT 1, row_span INTEGER NOT NULL DEFAULT 1,
col_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, camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT, stream_selector TEXT,
web_url TEXT, web_url TEXT,
@ -537,4 +537,56 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
// empty cell — leave entity_id null // 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");
},
]; ];

View file

@ -559,7 +559,7 @@ export class Repository {
}): LayoutCell { }): LayoutCell {
// Resolve content fields from the entity (if given). The legacy columns // Resolve content fields from the entity (if given). The legacy columns
// remain populated for backward-compatible bundle generation. // 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 cameraId: number | null = input.camera_id ?? null;
let webUrl: string | null = input.web_url ?? null; let webUrl: string | null = input.web_url ?? null;
let htmlContent: string | null = input.html_content ?? null; let htmlContent: string | null = input.html_content ?? null;
@ -609,7 +609,7 @@ export class Repository {
.prepare( .prepare(
`UPDATE layout_cells `UPDATE layout_cells
SET entity_id = NULL, SET entity_id = NULL,
content_type = 'html', content_type = 'none',
camera_id = NULL, camera_id = NULL,
web_url = NULL, web_url = NULL,
html_content = NULL html_content = NULL

View file

@ -15,7 +15,7 @@ const cameraType = av.enum_(["rtsp", "onvif"] as const);
const streamRole = av.enum_(["main", "sub", "other"] as const); const streamRole = av.enum_(["main", "sub", "other"] as const);
const streamSelector = av.enum_(["auto", "main", "sub"] as const); const streamSelector = av.enum_(["auto", "main", "sub"] as const);
const layoutPriority = av.enum_(["hot", "normal", "cold"] 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( const cameraStream = av.object(
{ {

View file

@ -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}`);

View file

@ -12,7 +12,7 @@ export type StreamRole = "main" | "sub" | "other";
export type StreamSelector = "auto" | "main" | "sub"; export type StreamSelector = "auto" | "main" | "sub";
export type StreamPolicy = "auto" | "always_main" | "always_sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub";
export type LayoutPriority = "hot" | "normal" | "cold"; 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 type EntityType = "camera" | "html" | "web";
export interface Entity { export interface Entity {

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="88" viewBox="0 0 360 88" role="img" aria-labelledby="title desc">
<title id="title">BetterFrame logo</title>
<desc id="desc">BetterFrame wordmark with a display frame and multi-camera grid icon.</desc>
<defs>
<linearGradient id="bf-accent" x1="20" y1="18" x2="68" y2="66" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="0.58" stop-color="#2563eb"/>
<stop offset="1" stop-color="#14b8a6"/>
</linearGradient>
<linearGradient id="bf-panel" x1="18" y1="16" x2="70" y2="70" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#252547"/>
<stop offset="1" stop-color="#111827"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="72" height="72" rx="16" fill="#0f172a"/>
<rect x="20" y="22" width="48" height="38" rx="5" fill="url(#bf-panel)" stroke="#475569" stroke-width="2"/>
<path d="M25 33h38M25 45h38M37 27v28M50 27v28" stroke="#64748b" stroke-width="2" stroke-linecap="round"/>
<path d="M25 28a1 1 0 0 1 1-1h24v18H25V28z" fill="#172554" opacity=".76"/>
<path d="M25 28a1 1 0 0 1 1-1h24v18H25V28z" fill="none" stroke="url(#bf-accent)" stroke-width="4" stroke-linejoin="round"/>
<path d="M32 65h24" stroke="url(#bf-accent)" stroke-width="4" stroke-linecap="round"/>
<path d="M44 60v7" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>
<text x="100" y="49" fill="#111827" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="33" font-weight="760" letter-spacing="0">BetterFrame</text>
<text x="102" y="68" fill="#64748b" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="12" font-weight="600" letter-spacing="1.3">MULTI-CAMERA DISPLAY</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">BetterFrame mark</title>
<desc id="desc">A display frame with a multi-camera grid and highlighted active frame.</desc>
<defs>
<linearGradient id="bf-accent" x1="13" y1="12" x2="51" y2="52" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="0.58" stop-color="#2563eb"/>
<stop offset="1" stop-color="#14b8a6"/>
</linearGradient>
<linearGradient id="bf-panel" x1="12" y1="10" x2="52" y2="54" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#252547"/>
<stop offset="1" stop-color="#111827"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<rect x="10" y="12" width="44" height="36" rx="5" fill="url(#bf-panel)" stroke="#475569" stroke-width="2"/>
<path d="M15 22h34M15 34h34M27 17v26M39 17v26" stroke="#64748b" stroke-width="2" stroke-linecap="round"/>
<path d="M15 18a1 1 0 0 1 1-1h22a1 1 0 0 1 1 1v16H15V18z" fill="#172554" opacity=".76"/>
<path d="M15 18a1 1 0 0 1 1-1h22a1 1 0 0 1 1 1v16H15V18z" fill="none" stroke="url(#bf-accent)" stroke-width="4" stroke-linejoin="round"/>
<path d="M21 51h22" stroke="url(#bf-accent)" stroke-width="4" stroke-linecap="round"/>
<path d="M32 48v5" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1517,6 +1517,7 @@ function cellLabel(
if (c.content_type === "camera" && c.camera_id) { if (c.content_type === "camera" && c.camera_id) {
return cameraById.get(c.camera_id)?.name ?? `cam #${String(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 === "web") return c.web_url ? `Web: ${c.web_url}` : "Web";
if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)"; if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)";
return "Empty"; return "Empty";
@ -1619,7 +1620,8 @@ export function renderCell(
// Read mode. Empty when no entity is bound. // Read mode. Empty when no entity is bound.
const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null; const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null;
const isEmpty = !ent && ( 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 === "camera" && !c.camera_id)
|| (c.content_type === "web" && !c.web_url) || (c.content_type === "web" && !c.web_url)
); );

View file

@ -38,6 +38,7 @@ function Sidebar(props: { activeNav?: string }) {
return ( return (
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<img src="/static/betterframe-mark.svg" alt="" class="brand-mark" />
<strong>BetterFrame</strong> <strong>BetterFrame</strong>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@ -65,6 +66,7 @@ export function Layout(props: PageProps) {
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{props.title} BetterFrame</title> <title>{props.title} BetterFrame</title>
<link rel="icon" href="/static/betterframe-mark.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/static/app.css" /> <link rel="stylesheet" href="/static/app.css" />
<style>{css(baseStyles as Parameters<typeof css>[0])}</style> <style>{css(baseStyles as Parameters<typeof css>[0])}</style>
</head> </head>
@ -99,6 +101,7 @@ export function MinimalLayout(props: { title: string; flash?: PageProps["flash"]
<Layout title={props.title} minimal flash={props.flash}> <Layout title={props.title} minimal flash={props.flash}>
<div class="center-card"> <div class="center-card">
<div class="card"> <div class="card">
<img src="/static/betterframe-logo.svg" alt="BetterFrame" class="auth-logo" />
<h1 class="card-title">{props.title}</h1> <h1 class="card-title">{props.title}</h1>
{props.children} {props.children}
</div> </div>
@ -132,7 +135,15 @@ const baseStyles = {
height: "100vh", height: "100vh",
overflowY: "auto" as const, overflowY: "auto" as const,
}, },
".sidebar-brand": { padding: "1.25rem 1rem", fontSize: "1.1rem", borderBottom: "1px solid #2a2a4e" }, ".sidebar-brand": {
display: "flex",
alignItems: "center",
gap: "0.65rem",
padding: "1rem",
fontSize: "1.1rem",
borderBottom: "1px solid #2a2a4e",
},
".brand-mark": { width: "2rem", height: "2rem", display: "block", flex: "0 0 auto" },
".sidebar-nav": { padding: "0.5rem 0", display: "flex", flexDirection: "column", gap: "2px" }, ".sidebar-nav": { padding: "0.5rem 0", display: "flex", flexDirection: "column", gap: "2px" },
".nav-item": { ".nav-item": {
display: "flex", display: "flex",
@ -163,6 +174,7 @@ const baseStyles = {
".content": { flex: "1", padding: "1.5rem" }, ".content": { flex: "1", padding: "1.5rem" },
".minimal .content": { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" }, ".minimal .content": { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" },
".center-card": { width: "100%", maxWidth: "420px" }, ".center-card": { width: "100%", maxWidth: "420px" },
".auth-logo": { display: "block", width: "220px", maxWidth: "100%", height: "auto", margin: "0 0 1.25rem" },
".card": { ".card": {
backgroundColor: "#fff", backgroundColor: "#fff",
borderRadius: "8px", borderRadius: "8px",