mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
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:
parent
b3c17a9d53
commit
026325ccd0
11 changed files with 220 additions and 39 deletions
|
|
@ -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("");
|
||||
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 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 {
|
||||
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));
|
||||
add_css(&label, "label { color: #666; font-size: 14px; background-color: #111; }");
|
||||
label.set_vexpand(true);
|
||||
label.set_hexpand(true);
|
||||
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<gtk::Widget>, css: &str) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
19
server/src/scripts/copy-web-static.ts
Normal file
19
server/src/scripts/copy-web-static.ts
Normal 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}`);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
24
server/src/web-static/betterframe-logo.svg
Normal file
24
server/src/web-static/betterframe-logo.svg
Normal 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 |
22
server/src/web-static/betterframe-mark.svg
Normal file
22
server/src/web-static/betterframe-mark.svg
Normal 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 |
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
return (
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<img src="/static/betterframe-mark.svg" alt="" class="brand-mark" />
|
||||
<strong>BetterFrame</strong>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
|
|
@ -65,6 +66,7 @@ export function Layout(props: PageProps) {
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{props.title} — BetterFrame</title>
|
||||
<link rel="icon" href="/static/betterframe-mark.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
<style>{css(baseStyles as Parameters<typeof css>[0])}</style>
|
||||
</head>
|
||||
|
|
@ -99,6 +101,7 @@ export function MinimalLayout(props: { title: string; flash?: PageProps["flash"]
|
|||
<Layout title={props.title} minimal flash={props.flash}>
|
||||
<div class="center-card">
|
||||
<div class="card">
|
||||
<img src="/static/betterframe-logo.svg" alt="BetterFrame" class="auth-logo" />
|
||||
<h1 class="card-title">{props.title}</h1>
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
@ -132,7 +135,15 @@ const baseStyles = {
|
|||
height: "100vh",
|
||||
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" },
|
||||
".nav-item": {
|
||||
display: "flex",
|
||||
|
|
@ -163,6 +174,7 @@ const baseStyles = {
|
|||
".content": { flex: "1", padding: "1.5rem" },
|
||||
".minimal .content": { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" },
|
||||
".center-card": { width: "100%", maxWidth: "420px" },
|
||||
".auth-logo": { display: "block", width: "220px", maxWidth: "100%", height: "auto", margin: "0 0 1.25rem" },
|
||||
".card": {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "8px",
|
||||
|
|
|
|||
Loading…
Reference in a new issue