Admin UI: Linear.app dark theme — near-black canvas, indigo-violet accent, Inter font Display: Minimal, Minimal Color, Bold, Analog Clock, Magazine, Cards, Kiosk, Classic
1644 lines
62 KiB
HTML
1644 lines
62 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>epaper-dashboard</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;510;590&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230a0a0a'/%3E%3Crect x='10' y='10' width='80' height='80' fill='%23fafafa'/%3E%3Crect x='15' y='15' width='35' height='35' fill='%230080ff'/%3E%3Crect x='50' y='15' width='35' height='35' fill='%23ff0080'/%3E%3Crect x='15' y='50' width='35' height='35' fill='%2300cc00'/%3E%3Crect x='50' y='50' width='35' height='35' fill='%23ff8000'/%3E%3C/svg%3E">
|
||
<style>
|
||
/* ================================================================
|
||
LINEAR DESIGN SYSTEM — epaper-dashboard Admin UI
|
||
Based on Linear.app design system
|
||
================================================================ */
|
||
:root {
|
||
/* Background Surfaces — near-black dark mode */
|
||
--bg: #08090a; /* marketing black */
|
||
--surface: #0f1011; /* panel dark */
|
||
--surface-2: #191a1b; /* elevated surface */
|
||
--surface-3: #28282c; /* hover/lighter surface */
|
||
|
||
/* Text */
|
||
--fg: #f7f8f8; /* primary white (not pure) */
|
||
--fg-muted: #d0d6e0; /* silver-gray body */
|
||
--fg-dim: #8a8f98; /* tertiary/muted */
|
||
--fg-subtle: #62666d; /* quaternary/timestamps */
|
||
|
||
/* Brand Accent — Linear indigo-violet */
|
||
--accent: #5e6ad2; /* brand indigo */
|
||
--accent-bright: #7170ff; /* interactive accent */
|
||
--accent-hover: #828fff; /* hover state */
|
||
|
||
/* Status */
|
||
--ok: #27a644; /* success green */
|
||
--success: #10b981; /* emerald */
|
||
--warn: #fbbf24; /* amber */
|
||
--alert: #f87171; /* red */
|
||
--info: #60a5fa; /* blue */
|
||
|
||
/* Borders — semi-transparent white */
|
||
--border: rgba(255,255,255,0.08); /* standard */
|
||
--border-subtle: rgba(255,255,255,0.05); /* subtle/default */
|
||
--border-light: rgba(255,255,255,0.12); /* lighter */
|
||
|
||
/* Glow */
|
||
--accent-glow: rgba(113,112,255,0.15);
|
||
--ok-glow: rgba(39,166,68,0.25);
|
||
--warn-glow: rgba(251,191,36,0.25);
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
font-feature-settings: "cv01", "ss03";
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
-webkit-font-smoothing: antialiased;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 24px 32px 80px;
|
||
}
|
||
|
||
/* ============ Header ============ */
|
||
.app-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 20px 0;
|
||
margin-bottom: 32px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.brand-logo {
|
||
width: 34px; height: 34px;
|
||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-bright) 100%);
|
||
border-radius: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.brand-logo svg { color: white; }
|
||
.brand-text h1 {
|
||
font-size: 1.1em; font-weight: 510;
|
||
letter-spacing: -0.02em;
|
||
color: var(--fg);
|
||
}
|
||
.brand-text .sub {
|
||
font-size: 0.78em; color: var(--fg-dim);
|
||
font-family: ui-monospace, 'SF Mono', monospace;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex; gap: 10px; align-items: center;
|
||
}
|
||
|
||
/* ============ Net-Status ============ */
|
||
.net-status {
|
||
display: flex; align-items: center; gap: 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 8px; padding: 8px 14px;
|
||
font-size: 0.85em;
|
||
}
|
||
.net-status .mode-dot {
|
||
width: 7px; height: 7px; border-radius: 50%;
|
||
background: var(--fg-subtle);
|
||
}
|
||
.net-status.online .mode-dot {
|
||
background: var(--ok);
|
||
box-shadow: 0 0 6px var(--ok-glow);
|
||
}
|
||
.net-status.ap .mode-dot {
|
||
background: var(--warn);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
.net-status.offline .mode-dot { background: var(--alert); }
|
||
.net-status.connecting .mode-dot {
|
||
background: var(--accent-bright);
|
||
animation: pulse 1s ease-in-out infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.35; }
|
||
}
|
||
.net-status .info-block { display: flex; flex-direction: column; line-height: 1.3; }
|
||
.net-status .mode-label { font-weight: 510; font-size: 0.88em; }
|
||
.net-status .mode-meta { color: var(--fg-dim); font-size: 0.85em; }
|
||
.net-status .mode-error { color: var(--alert); font-size: 0.82em; margin-top: 2px; }
|
||
|
||
/* ============ Buttons — Linear ghost style ============ */
|
||
button, .btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 7px 14px;
|
||
background: rgba(255,255,255,0.02);
|
||
border: 1px solid rgba(36,40,44,1);
|
||
color: var(--fg-muted);
|
||
border-radius: 6px; cursor: pointer; font-size: 0.88em;
|
||
font-family: inherit; font-weight: 400;
|
||
transition: all 0.12s ease;
|
||
}
|
||
button:hover, .btn:hover {
|
||
background: rgba(255,255,255,0.04);
|
||
color: var(--fg);
|
||
border-color: rgba(255,255,255,0.08);
|
||
}
|
||
button:active { transform: scale(0.98); }
|
||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
button.primary {
|
||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||
}
|
||
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||
button.danger { color: var(--alert); border-color: rgba(248,113,113,0.3); }
|
||
button.danger:hover { background: rgba(248,113,113,0.08); }
|
||
button.warn { color: var(--warn); border-color: rgba(251,191,36,0.3); }
|
||
button.ghost { background: transparent; border-color: transparent; }
|
||
button.ghost:hover { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.08); }
|
||
button.icon { padding: 6px 8px; }
|
||
button.refresh {
|
||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||
}
|
||
button.refresh:hover {
|
||
background: var(--accent-hover);
|
||
box-shadow: 0 2px 8px var(--accent-glow);
|
||
}
|
||
|
||
/* ============ Tabs ============ */
|
||
.tabs {
|
||
display: flex; gap: 2px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
margin-bottom: 28px;
|
||
}
|
||
.tab {
|
||
padding: 10px 16px;
|
||
background: transparent; border: none;
|
||
border-bottom: 2px solid transparent;
|
||
color: var(--fg-dim); cursor: pointer;
|
||
font-size: 0.9em; font-weight: 400;
|
||
font-family: inherit;
|
||
transition: all 0.12s ease;
|
||
margin-bottom: -1px;
|
||
}
|
||
.tab:hover { color: var(--fg-muted); }
|
||
.tab.active {
|
||
color: var(--fg); border-bottom-color: var(--accent-bright);
|
||
font-weight: 510;
|
||
}
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
/* ============ Cards — Linear elevated surface ============ */
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
padding: 20px 22px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.card-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 16px; padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.card-header h2, .card-header h3 {
|
||
font-size: 0.9em; font-weight: 510;
|
||
letter-spacing: -0.01em;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.card-header .badge {
|
||
font-size: 0.7em; padding: 2px 8px;
|
||
background: rgba(255,255,255,0.05);
|
||
color: var(--fg-dim);
|
||
border-radius: 4px;
|
||
font-family: ui-monospace, 'SF Mono', monospace;
|
||
font-weight: 400;
|
||
}
|
||
|
||
/* ============ Layout Editor ============ */
|
||
.layout-toolbar {
|
||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.add-form {
|
||
display: flex; gap: 8px; align-items: center;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 8px;
|
||
padding: 4px 4px 4px 14px;
|
||
}
|
||
.add-form select, .add-form input {
|
||
border: none; background: transparent; color: var(--fg);
|
||
padding: 6px 8px; font-family: inherit; font-size: 0.88em;
|
||
}
|
||
.add-form select { padding-right: 20px; }
|
||
.add-form select:focus, .add-form input:focus { outline: none; }
|
||
.add-form button { padding: 6px 12px; font-size: 0.85em; }
|
||
|
||
.grid-preview {
|
||
display: grid;
|
||
grid-template-columns: repeat({{ grid.cols }}, 1fr);
|
||
grid-template-rows: repeat({{ grid.rows }}, 120px);
|
||
gap: 6px;
|
||
background: var(--border-subtle);
|
||
padding: 6px;
|
||
border-radius: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.grid-cell {
|
||
background: var(--surface-2);
|
||
border-radius: 5px;
|
||
}
|
||
.grid-item {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
display: flex; flex-direction: column;
|
||
justify-content: space-between;
|
||
overflow: hidden;
|
||
position: relative;
|
||
transition: all 0.12s ease;
|
||
cursor: grab;
|
||
}
|
||
.grid-item:hover {
|
||
border-color: var(--accent-bright);
|
||
background: var(--surface-2);
|
||
}
|
||
.grid-item.dragging { opacity: 0.4; }
|
||
.grid-item.oob {
|
||
border-color: rgba(248,113,113,0.5);
|
||
background: rgba(248,113,113,0.04);
|
||
}
|
||
.grid-item .item-label {
|
||
font-weight: 510; font-size: 0.85em; line-height: 1.2;
|
||
color: var(--fg);
|
||
}
|
||
.grid-item .item-meta {
|
||
font-size: 0.7em; color: var(--fg-dim);
|
||
font-family: ui-monospace, 'SF Mono', monospace;
|
||
}
|
||
.grid-item .item-category {
|
||
display: inline-block;
|
||
font-size: 0.62em; padding: 2px 6px;
|
||
background: rgba(113,112,255,0.1);
|
||
color: var(--accent-bright);
|
||
border-radius: 3px;
|
||
text-transform: uppercase; letter-spacing: 0.06em;
|
||
font-weight: 510;
|
||
margin-top: 4px;
|
||
}
|
||
.grid-item .item-controls {
|
||
position: absolute; top: 6px; right: 6px;
|
||
display: flex; gap: 2px; opacity: 0;
|
||
transition: opacity 0.12s;
|
||
}
|
||
.grid-item:hover .item-controls { opacity: 1; }
|
||
.grid-item .item-controls button {
|
||
padding: 2px 5px; font-size: 0.68em; line-height: 1.3;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.layout-status {
|
||
font-size: 0.82em; color: var(--fg-dim);
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.layout-status.has-warnings { color: var(--warn); }
|
||
.layout-status::before {
|
||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
|
||
/* ============ Plugin-Config Cards ============ */
|
||
.plugin-config-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
padding: 16px 20px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.plugin-config-card h3 {
|
||
font-size: 0.9em; font-weight: 510;
|
||
letter-spacing: -0.01em;
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.plugin-config-card h3 .badge {
|
||
font-size: 0.7em; padding: 2px 8px;
|
||
background: rgba(255,255,255,0.05);
|
||
color: var(--fg-dim);
|
||
border-radius: 4px;
|
||
font-family: ui-monospace, 'SF Mono', monospace;
|
||
font-weight: 400;
|
||
}
|
||
.plugin-config-card .desc {
|
||
font-size: 0.82em; color: var(--fg-dim); margin-bottom: 14px;
|
||
}
|
||
.plugin-config-card label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.76em; color: var(--fg-dim);
|
||
text-transform: uppercase; letter-spacing: 0.06em;
|
||
font-weight: 500;
|
||
}
|
||
.plugin-config-card input[type=text],
|
||
.plugin-config-card input[type=number],
|
||
.plugin-config-card input[type=password],
|
||
.plugin-config-card select {
|
||
width: 100%; padding: 9px 12px;
|
||
background: rgba(255,255,255,0.02);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 6px;
|
||
color: var(--fg);
|
||
font-size: 0.88em; font-family: inherit;
|
||
transition: all 0.12s;
|
||
}
|
||
.plugin-config-card input:focus, .plugin-config-card select:focus {
|
||
outline: none;
|
||
border-color: var(--accent-bright);
|
||
box-shadow: 0 0 0 3px rgba(113,112,255,0.1);
|
||
}
|
||
.plugin-config-card input[type=checkbox] {
|
||
width: 16px; height: 16px;
|
||
accent-color: var(--accent-bright);
|
||
}
|
||
.plugin-config-card .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.plugin-config-card .checkbox-row label {
|
||
margin: 0; text-transform: none; letter-spacing: 0; font-size: 0.88em;
|
||
color: var(--fg-muted);
|
||
}
|
||
.plugin-config-card .help {
|
||
font-size: 0.76em; color: var(--fg-subtle);
|
||
margin-top: 4px; font-style: italic;
|
||
}
|
||
.plugin-config-card .secret-row {
|
||
display: flex; gap: 6px; align-items: stretch;
|
||
}
|
||
.plugin-config-card .secret-row input {
|
||
flex: 1;
|
||
font-family: ui-monospace, 'SF Mono', monospace;
|
||
}
|
||
.plugin-config-card .secret-row button {
|
||
padding: 0 12px;
|
||
color: var(--alert);
|
||
border-color: rgba(248,113,113,0.3);
|
||
}
|
||
.plugin-config-card .actions {
|
||
display: flex; gap: 8px; margin-top: 16px;
|
||
padding-top: 12px; border-top: 1px solid var(--border-subtle);
|
||
}
|
||
.plugin-config-card .save-status {
|
||
font-size: 0.78em; color: var(--success);
|
||
display: none; align-items: center; gap: 4px;
|
||
}
|
||
.plugin-config-card .save-status.visible { display: inline-flex; }
|
||
|
||
/* ============ Preview ============ */
|
||
.preview-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
padding: 20px 22px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.preview-card .preview-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.preview-card .preview-header h2 {
|
||
font-size: 0.9em; font-weight: 510;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.preview-card .meta { color: var(--fg-dim); font-size: 0.82em; }
|
||
.preview-card img {
|
||
max-width: 100%; height: auto; display: block;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border-subtle);
|
||
image-rendering: pixelated;
|
||
}
|
||
|
||
/* ============ Network panel ============ */
|
||
.network-grid {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||
}
|
||
.network-grid > .card { margin-bottom: 0; }
|
||
.network-grid .full-width { grid-column: 1 / -1; }
|
||
.network-grid h3 {
|
||
font-size: 0.9em; font-weight: 510;
|
||
letter-spacing: -0.01em;
|
||
margin-bottom: 12px;
|
||
}
|
||
.network-grid label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.76em; color: var(--fg-dim);
|
||
text-transform: uppercase; letter-spacing: 0.06em;
|
||
}
|
||
.network-grid input, .network-grid select {
|
||
width: 100%; padding: 9px 12px;
|
||
background: rgba(255,255,255,0.02);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 6px; color: var(--fg);
|
||
font-family: inherit; font-size: 0.88em;
|
||
}
|
||
.network-grid input:focus, .network-grid select:focus {
|
||
outline: none; border-color: var(--accent-bright);
|
||
}
|
||
.network-grid .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.network-grid .checkbox-row label { margin: 0; text-transform: none; letter-spacing: 0; }
|
||
.network-grid ul#savedList { list-style: none; padding: 0; margin: 0; }
|
||
.network-grid ul#savedList li {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 8px 0; border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.network-grid ul#savedList li:last-child { border-bottom: 0; }
|
||
.network-grid ul#savedList code {
|
||
font-family: ui-monospace, 'SF Mono', monospace; font-size: 0.85em;
|
||
}
|
||
.network-grid details {
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 6px; padding: 12px 16px;
|
||
}
|
||
.network-grid details summary {
|
||
cursor: pointer; font-weight: 510; color: var(--fg-dim); font-size: 0.88em;
|
||
}
|
||
.network-grid details code {
|
||
background: var(--bg); padding: 2px 6px; border-radius: 3px;
|
||
font-size: 0.88em;
|
||
}
|
||
|
||
/* ============ Empty state ============ */
|
||
.empty-state {
|
||
text-align: center; padding: 48px 20px; color: var(--fg-dim);
|
||
}
|
||
.empty-state .icon { font-size: 2em; margin-bottom: 10px; opacity: 0.4; }
|
||
.empty-state .hint { font-size: 0.85em; margin-top: 4px; color: var(--fg-subtle); }
|
||
|
||
/* ============ Admin Theme Switcher ============ */
|
||
.admin-theme-switcher {
|
||
display: flex; gap: 2px;
|
||
background: rgba(255,255,255,0.02);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 6px; padding: 3px;
|
||
}
|
||
.theme-btn {
|
||
width: 26px; height: 26px;
|
||
border: none; border-radius: 4px;
|
||
background: transparent; color: var(--fg-dim);
|
||
font-size: 0.75em; font-weight: 510; font-family: inherit;
|
||
cursor: pointer; transition: all 0.12s;
|
||
}
|
||
.theme-btn:hover { background: rgba(255,255,255,0.05); color: var(--fg); }
|
||
.theme-btn.active {
|
||
background: var(--accent-bright); color: #fff;
|
||
}
|
||
|
||
/* ============ Display Design Picker ============ */
|
||
.design-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
.design-option {
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.12s;
|
||
text-align: center;
|
||
}
|
||
.design-option:hover {
|
||
border-color: var(--accent-bright);
|
||
background: var(--surface-3);
|
||
}
|
||
.design-option.selected {
|
||
border-color: var(--accent-bright);
|
||
background: rgba(113,112,255,0.06);
|
||
box-shadow: 0 0 0 1px var(--accent-bright);
|
||
}
|
||
.design-option .design-name {
|
||
font-size: 0.82em; font-weight: 510; margin-bottom: 4px;
|
||
}
|
||
.design-option .design-desc {
|
||
font-size: 0.72em; color: var(--fg-dim); line-height: 1.4;
|
||
}
|
||
|
||
/* ============ Responsive ============ */
|
||
@media (max-width: 900px) {
|
||
.container { padding: 16px; }
|
||
.network-grid { grid-template-columns: 1fr; }
|
||
.app-header { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||
.header-actions { width: 100%; flex-wrap: wrap; }
|
||
}
|
||
|
||
/* ============ Scrollbar ============ */
|
||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||
::-webkit-scrollbar-track { background: var(--bg); }
|
||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
|
||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.12); }
|
||
</style>
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
margin: 0;
|
||
line-height: 1.5;
|
||
font-feature-settings: "cv11", "ss01";
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 24px 32px 80px;
|
||
}
|
||
|
||
/* ============ Header ============ */
|
||
.app-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 20px 0;
|
||
margin-bottom: 32px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.brand-logo {
|
||
width: 36px; height: 36px;
|
||
background: linear-gradient(135deg, #0080ff 0%, #6d8eff 50%, #f87171 100%);
|
||
border-radius: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
box-shadow: 0 4px 12px var(--accent-glow);
|
||
}
|
||
.brand-logo svg { color: white; }
|
||
.brand-text h1 {
|
||
margin: 0; font-size: 1.15em; font-weight: 600; letter-spacing: -0.01em;
|
||
}
|
||
.brand-text .sub {
|
||
color: var(--fg-dim); font-size: 0.78em; font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex; gap: 12px; align-items: center;
|
||
}
|
||
|
||
/* ============ Net-Status Card ============ */
|
||
.net-status {
|
||
display: flex; align-items: center; gap: 12px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 8px 14px;
|
||
font-size: 0.85em;
|
||
}
|
||
.net-status .mode-dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--fg-dim);
|
||
box-shadow: 0 0 0 0 var(--fg-dim);
|
||
}
|
||
.net-status.online .mode-dot {
|
||
background: var(--ok);
|
||
box-shadow: 0 0 8px var(--ok);
|
||
}
|
||
.net-status.ap .mode-dot {
|
||
background: var(--warn);
|
||
box-shadow: 0 0 8px var(--warn);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
.net-status.offline .mode-dot { background: var(--alert); }
|
||
.net-status.connecting .mode-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; }
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
.net-status .info-block { display: flex; flex-direction: column; line-height: 1.3; }
|
||
.net-status .mode-label { font-weight: 600; }
|
||
.net-status .mode-meta { color: var(--fg-muted); font-size: 0.9em; }
|
||
.net-status .mode-error { color: var(--alert); font-size: 0.85em; margin-top: 2px; }
|
||
|
||
/* ============ Buttons ============ */
|
||
button, .btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 8px 14px; border: 1px solid var(--border-light);
|
||
background: var(--surface); color: var(--fg);
|
||
border-radius: 6px; cursor: pointer; font-size: 0.9em;
|
||
font-family: inherit;
|
||
transition: all 0.15s ease;
|
||
}
|
||
button:hover, .btn:hover {
|
||
background: var(--surface-2); border-color: var(--accent);
|
||
}
|
||
button:active, .btn:active { transform: scale(0.98); }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
button.primary {
|
||
background: var(--accent); color: white; border-color: var(--accent);
|
||
}
|
||
button.primary:hover { background: #5d7eef; border-color: #5d7eef; }
|
||
button.danger {
|
||
color: var(--alert); border-color: var(--alert);
|
||
}
|
||
button.danger:hover { background: rgba(248, 113, 113, 0.1); }
|
||
button.warn {
|
||
background: var(--warn); color: #1a1a1a; border-color: var(--warn);
|
||
}
|
||
button.ghost {
|
||
background: transparent; border-color: transparent;
|
||
}
|
||
button.ghost:hover { background: var(--surface-2); border-color: var(--border); }
|
||
button.icon { padding: 6px 8px; }
|
||
button.refresh {
|
||
background: var(--accent); color: white; border-color: var(--accent);
|
||
box-shadow: 0 2px 8px var(--accent-glow);
|
||
}
|
||
button.refresh:hover { box-shadow: 0 4px 16px var(--accent-glow); }
|
||
|
||
/* ============ Tabs ============ */
|
||
.tabs {
|
||
display: flex; gap: 4px;
|
||
border-bottom: 1px solid var(--border);
|
||
margin-bottom: 24px;
|
||
}
|
||
.tab {
|
||
padding: 10px 16px;
|
||
background: transparent; border: none; border-bottom: 2px solid transparent;
|
||
color: var(--fg-muted); cursor: pointer;
|
||
font-size: 0.92em; font-family: inherit;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.tab:hover { color: var(--fg); }
|
||
.tab.active {
|
||
color: var(--fg); border-bottom-color: var(--accent);
|
||
}
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
/* ============ Cards ============ */
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.card-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 16px; padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.card-header h2, .card-header h3 {
|
||
margin: 0; font-size: 0.95em; font-weight: 600;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.card-header .badge {
|
||
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--surface-2); color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
|
||
/* ============ Layout Editor ============ */
|
||
.layout-toolbar {
|
||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.add-form {
|
||
display: flex; gap: 8px; align-items: center;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 4px 4px 4px 12px;
|
||
}
|
||
.add-form select, .add-form input {
|
||
border: none; background: transparent; color: var(--fg);
|
||
padding: 6px 8px; font-family: inherit; font-size: 0.9em;
|
||
}
|
||
.add-form select { padding-right: 24px; }
|
||
.add-form select:focus, .add-form input:focus { outline: none; }
|
||
.add-form button { padding: 6px 12px; font-size: 0.85em; }
|
||
|
||
.grid-preview {
|
||
display: grid;
|
||
grid-template-columns: repeat({{ grid.cols }}, 1fr);
|
||
grid-template-rows: repeat({{ grid.rows }}, 120px);
|
||
gap: 6px;
|
||
background: var(--border);
|
||
padding: 6px;
|
||
border-radius: 8px;
|
||
margin-bottom: 12px;
|
||
min-height: 200px;
|
||
}
|
||
.grid-cell {
|
||
background: var(--surface-2);
|
||
border-radius: 4px;
|
||
transition: background 0.15s;
|
||
}
|
||
.grid-item {
|
||
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
overflow: hidden;
|
||
position: relative;
|
||
transition: all 0.15s ease;
|
||
cursor: grab;
|
||
}
|
||
.grid-item:hover {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 1px var(--accent);
|
||
}
|
||
.grid-item.dragging { opacity: 0.5; }
|
||
.grid-item.oob {
|
||
border-color: var(--alert);
|
||
background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%);
|
||
}
|
||
.grid-item .item-label {
|
||
font-weight: 600; font-size: 0.85em; line-height: 1.2;
|
||
color: var(--fg);
|
||
}
|
||
.grid-item .item-meta {
|
||
font-size: 0.7em; color: var(--fg-dim);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.grid-item .item-category {
|
||
display: inline-block;
|
||
font-size: 0.65em; padding: 1px 6px; border-radius: 3px;
|
||
background: var(--accent-glow); color: var(--accent);
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
margin-top: 4px;
|
||
}
|
||
.grid-item .item-controls {
|
||
position: absolute; top: 6px; right: 6px;
|
||
display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s;
|
||
}
|
||
.grid-item:hover .item-controls { opacity: 1; }
|
||
.grid-item .item-controls button {
|
||
padding: 2px 6px; font-size: 0.7em; line-height: 1.3;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
}
|
||
|
||
.layout-status {
|
||
font-size: 0.85em; color: var(--fg-muted);
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.layout-status.has-warnings { color: var(--warn); }
|
||
.layout-status::before {
|
||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
|
||
/* ============ Plugin-Config Cards ============ */
|
||
.plugin-config-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 16px 20px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.plugin-config-card h3 {
|
||
margin: 0 0 4px; font-size: 0.95em; font-weight: 600;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.plugin-config-card h3 .badge {
|
||
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--surface-2); color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.plugin-config-card .desc {
|
||
font-size: 0.82em; color: var(--fg-muted); margin-bottom: 14px;
|
||
}
|
||
.plugin-config-card label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.78em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
font-weight: 500;
|
||
}
|
||
.plugin-config-card input[type=text],
|
||
.plugin-config-card input[type=number],
|
||
.plugin-config-card input[type=password],
|
||
.plugin-config-card select {
|
||
width: 100%; padding: 8px 12px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--fg);
|
||
font-size: 0.9em;
|
||
font-family: inherit;
|
||
transition: all 0.15s;
|
||
}
|
||
.plugin-config-card input:focus, .plugin-config-card select:focus {
|
||
outline: none; border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
}
|
||
.plugin-config-card input[type=checkbox] {
|
||
width: 16px; height: 16px;
|
||
accent-color: var(--accent);
|
||
}
|
||
.plugin-config-card .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.plugin-config-card .checkbox-row label {
|
||
margin: 0; text-transform: none; letter-spacing: 0; font-size: 0.88em;
|
||
color: var(--fg);
|
||
}
|
||
.plugin-config-card .help {
|
||
font-size: 0.78em; color: var(--fg-dim);
|
||
margin-top: 4px; font-style: italic;
|
||
}
|
||
.plugin-config-card .secret-row {
|
||
display: flex; gap: 6px; align-items: stretch;
|
||
}
|
||
.plugin-config-card .secret-row input {
|
||
flex: 1; font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.plugin-config-card .secret-row button {
|
||
flex: 0 0 auto; padding: 0 10px;
|
||
color: var(--alert); border-color: var(--alert);
|
||
}
|
||
.plugin-config-card .actions {
|
||
display: flex; gap: 8px; margin-top: 16px;
|
||
padding-top: 12px; border-top: 1px solid var(--border);
|
||
}
|
||
.plugin-config-card .save-status {
|
||
font-size: 0.78em; color: var(--success);
|
||
display: none; align-items: center; gap: 4px;
|
||
}
|
||
.plugin-config-card .save-status.visible { display: inline-flex; }
|
||
.plugin-config-card .save-status::before {
|
||
content: '✓'; font-weight: bold;
|
||
}
|
||
|
||
/* ============ Preview ============ */
|
||
.preview-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.preview-card .preview-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.preview-card .preview-header h2 {
|
||
margin: 0; font-size: 0.95em; font-weight: 600;
|
||
}
|
||
.preview-card .meta {
|
||
color: var(--fg-muted); font-size: 0.85em;
|
||
}
|
||
.preview-card img {
|
||
max-width: 100%; height: auto; display: block;
|
||
border-radius: 6px; border: 1px solid var(--border);
|
||
image-rendering: pixelated;
|
||
}
|
||
|
||
/* ============ Network panel ============ */
|
||
.network-grid {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||
}
|
||
.network-grid > .card { margin-bottom: 0; }
|
||
.network-grid .full-width { grid-column: 1 / -1; }
|
||
.network-grid h3 {
|
||
margin: 0 0 12px; font-size: 0.95em; font-weight: 600;
|
||
}
|
||
.network-grid label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.78em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
}
|
||
.network-grid input, .network-grid select {
|
||
width: 100%; padding: 8px 12px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--fg);
|
||
font-family: inherit;
|
||
}
|
||
.network-grid input:focus, .network-grid select:focus {
|
||
outline: none; border-color: var(--accent);
|
||
}
|
||
.network-grid .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.network-grid .checkbox-row label { margin: 0; text-transform: none; letter-spacing: 0; }
|
||
.network-grid ul#savedList {
|
||
list-style: none; padding: 0; margin: 0;
|
||
}
|
||
.network-grid ul#savedList li {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.network-grid ul#savedList li:last-child { border-bottom: 0; }
|
||
.network-grid ul#savedList code {
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
font-size: 0.85em;
|
||
}
|
||
.network-grid details {
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 12px 16px;
|
||
}
|
||
.network-grid details summary {
|
||
cursor: pointer; font-weight: 500; color: var(--fg-muted);
|
||
}
|
||
.network-grid details code {
|
||
background: var(--bg); padding: 2px 6px; border-radius: 3px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* ============ Empty state ============ */
|
||
.empty-state {
|
||
text-align: center; padding: 40px 20px;
|
||
color: var(--fg-muted);
|
||
}
|
||
.empty-state .icon {
|
||
font-size: 2em; margin-bottom: 8px; opacity: 0.5;
|
||
}
|
||
.empty-state .hint { font-size: 0.85em; margin-top: 4px; }
|
||
|
||
/* ============ Responsive ============ */
|
||
@media (max-width: 900px) {
|
||
.container { padding: 16px; }
|
||
.network-grid { grid-template-columns: 1fr; }
|
||
.app-header { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||
.header-actions { width: 100%; flex-wrap: wrap; }
|
||
}
|
||
|
||
/* ============ Scrollbar ============ */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: var(--bg); }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||
|
||
/* ============ Admin Theme Switcher ============ */
|
||
.admin-theme-switcher {
|
||
display: flex;
|
||
gap: 2px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 3px;
|
||
}
|
||
.theme-btn {
|
||
width: 28px; height: 28px;
|
||
border: none; border-radius: 4px;
|
||
background: transparent; color: var(--fg-muted);
|
||
font-size: 0.8em; font-weight: 700; font-family: monospace;
|
||
cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.theme-btn:hover { background: var(--surface-2); color: var(--fg); }
|
||
.theme-btn.active {
|
||
background: var(--accent); color: #fff;
|
||
}
|
||
[data-theme="B"] .theme-btn.active { background: var(--accent); color: #000; }
|
||
[data-theme="C"] .theme-btn.active { background: var(--accent); color: #fff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<header class="app-header">
|
||
<div class="brand">
|
||
<div class="brand-logo">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<line x1="3" y1="9" x2="21" y2="9"/>
|
||
<line x1="9" y1="21" x2="9" y2="9"/>
|
||
</svg>
|
||
</div>
|
||
<div class="brand-text">
|
||
<h1>epaper-dashboard</h1>
|
||
<div class="sub">7.3" ACeP · 4×4 grid</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<div class="net-status" id="netStatus">
|
||
<div class="mode-dot"></div>
|
||
<div class="info-block">
|
||
<div class="mode-label" id="netMode">—</div>
|
||
<div class="mode-meta">
|
||
<span id="netSsid"></span> · <span id="netIp"></span>
|
||
</div>
|
||
<div class="mode-error" id="netError"></div>
|
||
</div>
|
||
</div>
|
||
<div class="admin-theme-switcher" title="Admin UI Theme">
|
||
<button class="theme-btn active" data-admin-theme="A" onclick="setAdminTheme('A')" title="Dark Minimal">A</button>
|
||
<button class="theme-btn" data-admin-theme="B" onclick="setAdminTheme('B')" title="Retro Terminal">B</button>
|
||
<button class="theme-btn" data-admin-theme="C" onclick="setAdminTheme('C')" title="Warm Editorial">C</button>
|
||
</div>
|
||
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/>
|
||
<path d="M21 3v5h-5"/>
|
||
</svg>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="layout" onclick="switchTab('layout')">Layout</button>
|
||
<button class="tab" data-tab="plugins" onclick="switchTab('plugins')">Plugins</button>
|
||
<button class="tab" data-tab="settings" onclick="switchTab('settings')">Settings</button>
|
||
<button class="tab" data-tab="network" onclick="switchTab('network')">Network</button>
|
||
</div>
|
||
|
||
<!-- ========== LAYOUT TAB ========== -->
|
||
<div class="tab-content active" id="tab-layout">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Grid Layout <span class="badge">{{ grid.cols }}×{{ grid.rows }} · {{ layout_items|length }} items</span></h2>
|
||
<div class="layout-status" id="layoutStatus"></div>
|
||
</div>
|
||
|
||
<div class="layout-toolbar">
|
||
<form class="add-form" onsubmit="return addItem(event)">
|
||
<select id="newItemPlugin" required>
|
||
{% for w in widgets %}
|
||
<option value="{{ w.name }}">{{ w.label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="newItemSize" title="Default-Größe">
|
||
<option value="auto">auto</option>
|
||
{% for sp in size_presets %}
|
||
<option value="{{ sp[0] }}x{{ sp[1] }}">{{ sp[0] }}×{{ sp[1] }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<button type="submit" class="primary">+ Hinzufügen</button>
|
||
</form>
|
||
<button onclick="packAll()">Auto-Pack</button>
|
||
<button onclick="saveLayout()">Layout speichern</button>
|
||
</div>
|
||
|
||
<div id="gridPreview" class="grid-preview"></div>
|
||
<div class="meta" style="text-align: center;">
|
||
Position und Größe per Buttons am Item. Auto-Pack ordnet nach Größe (groß zuerst).
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-card">
|
||
<div class="preview-header">
|
||
<h2>Live Preview</h2>
|
||
<div class="meta" id="previewMeta">Auto-refresh alle 10s</div>
|
||
</div>
|
||
<img id="previewImg" src="/snapshot.png?t={{ now }}" alt="preview">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========== PLUGINS TAB ========== -->
|
||
<div class="tab-content" id="tab-plugins">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Plugin-Konfiguration</h2>
|
||
<div class="meta" id="pluginCount">—</div>
|
||
</div>
|
||
<div id="pluginConfigs"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========== SETTINGS TAB ========== -->
|
||
<div class="tab-content" id="tab-settings">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Refresh-Intervall</h2>
|
||
</div>
|
||
<form method="post" action="/config" style="display: flex; gap: 12px; align-items: end;">
|
||
<div style="flex: 1;">
|
||
<label style="display: block; margin-bottom: 6px; font-size: 0.82em; color: var(--fg-muted);">
|
||
Display-Refresh alle (Sekunden, min. 30 empfohlen für ACeP-Displays)
|
||
</label>
|
||
<input type="number" name="refresh_interval_s" min="30" max="3600"
|
||
value="{{ refresh_interval }}" step="10"
|
||
style="width: 100%; padding: 10px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 1em;">
|
||
</div>
|
||
<button type="submit" class="primary">Speichern</button>
|
||
</form>
|
||
<div class="meta" style="margin-top: 12px;">
|
||
ACeP-Displays vertragen keine schnellen Refreshes. Hersteller empfiehlt ≥180s.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Display Design</h2>
|
||
<span class="badge" id="currentDesignBadge">—</span>
|
||
</div>
|
||
<p style="font-size: 0.82em; color: var(--fg-dim); margin-bottom: 16px;">
|
||
bestimmt das Layout auf dem e-Paper-Display.
|
||
</p>
|
||
<div class="design-grid" id="designGrid">
|
||
<div class="design-option" data-design="minimal" onclick="setDisplayDesign('minimal')">
|
||
<div class="design-name">Minimal</div>
|
||
<div class="design-desc">Nur Content, kein UI-Chrome, maximal clean</div>
|
||
</div>
|
||
<div class="design-option" data-design="minimal_color" onclick="setDisplayDesign('minimal_color')">
|
||
<div class="design-name">Minimal Color</div>
|
||
<div class="design-desc">WinZiger Akzent-Strich pro Widget</div>
|
||
</div>
|
||
<div class="design-option" data-design="bold" onclick="setDisplayDesign('bold')">
|
||
<div class="design-name">Bold</div>
|
||
<div class="design-desc">Riesen-Typography, 1px-Rahmen, vollflächig</div>
|
||
</div>
|
||
<div class="design-option" data-design="analog" onclick="setDisplayDesign('analog')">
|
||
<div class="design-name">Analog Clock</div>
|
||
<div class="design-desc">2×2-Uhr als Ziffernblatt, Rest clean Panels</div>
|
||
</div>
|
||
<div class="design-option" data-design="magazine" onclick="setDisplayDesign('magazine')">
|
||
<div class="design-name">Magazine</div>
|
||
<div class="design-desc">Bold Typography, farbige Akzentstreifen links</div>
|
||
</div>
|
||
<div class="design-option" data-design="cards" onclick="setDisplayDesign('cards')">
|
||
<div class="design-name">Cards</div>
|
||
<div class="design-desc">iOS-Widget-Style, Emoji-Icons, warme Paneele</div>
|
||
</div>
|
||
<div class="design-option" data-design="kiosk" onclick="setDisplayDesign('kiosk')">
|
||
<div class="design-name">Kiosk</div>
|
||
<div class="design-desc">Dunkel, Daten-first, kompakte Info-Zellen</div>
|
||
</div>
|
||
<div class="design-option" data-design="classic" onclick="setDisplayDesign('classic')">
|
||
<div class="design-name">Classic</div>
|
||
<div class="design-desc">Original flaches Design, weißer Hintergrund</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>System</h2>
|
||
</div>
|
||
<dl style="display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0;">
|
||
<dt class="meta">Service:</dt><dd><code style="font-family: ui-monospace, monospace;">epaper-dashboard.service</code></dd>
|
||
<dt class="meta">Logs:</dt><dd><code style="font-family: ui-monospace, monospace;">journalctl -u epaper-dashboard -f</code></dd>
|
||
<dt class="meta">Config:</dt><dd><code style="font-family: ui-monospace, monospace;">config.json</code></dd>
|
||
<dt class="meta">Plugins:</dt><dd><code style="font-family: ui-monospace, monospace;">plugins/</code></dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========== NETWORK TAB ========== -->
|
||
<div class="tab-content" id="tab-network">
|
||
<div class="network-grid">
|
||
<div class="card">
|
||
<h3>Aktueller Status</h3>
|
||
<div id="netCurrent" class="meta">lade…</div>
|
||
<div style="display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;">
|
||
<button onclick="netReconnect()">Reconnect</button>
|
||
<button onclick="netDisconnect()">Disconnect</button>
|
||
<button class="warn" onclick="netForceAp()">Force AP</button>
|
||
<button class="danger" onclick="netStopAp()" id="netStopApBtn" style="display:none;">AP beenden</button>
|
||
</div>
|
||
<div class="help" id="netHint" style="margin-top: 12px;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>WLAN verbinden / wechseln</h3>
|
||
<label>Verfügbare Netzwerke</label>
|
||
<select id="ssidSelect" onchange="document.getElementById('ssidManual').value=this.value">
|
||
<option value="">— bitte wählen —</option>
|
||
</select>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="ssidManualToggle" onchange="document.getElementById('ssidManual').disabled=!this.checked">
|
||
<label for="ssidManualToggle">SSID manuell eingeben</label>
|
||
</div>
|
||
<input type="text" id="ssidManual" placeholder="SSID" disabled>
|
||
<label>Passwort</label>
|
||
<input type="password" id="wifiPwd" placeholder="WPA/WPA2 Passwort">
|
||
<label>Sicherheit</label>
|
||
<select id="wifiSecurity">
|
||
<option value="wpa-psk">WPA/WPA2/WPA3</option>
|
||
<option value="wep">WEP</option>
|
||
<option value="open">Offen</option>
|
||
</select>
|
||
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||
<button class="primary" onclick="netConnect()">Speichern & Verbinden</button>
|
||
</div>
|
||
<div class="help" id="netConnResult" style="margin-top: 8px;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Gespeicherte Netzwerke</h3>
|
||
<ul id="savedList"></ul>
|
||
</div>
|
||
|
||
<details class="card">
|
||
<summary>Recovery-AP Info</summary>
|
||
<p>Wenn das konfigurierte WLAN nicht erreichbar ist (z.B. Router defekt, falsches Passwort, Pi umgezogen), startet das System automatisch einen Recovery-Hotspot.</p>
|
||
<ul style="padding-left: 20px; line-height: 1.8;">
|
||
<li><strong>SSID:</strong> <code>epaper-recovery</code></li>
|
||
<li><strong>Passwort:</strong> <code>recovery1234</code></li>
|
||
<li><strong>URL im AP-Modus:</strong> <a href="http://10.42.0.1:8080" style="color: var(--accent);">http://10.42.0.1:8080</a></li>
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ============ Globale Daten ============
|
||
let layoutItems = {{ layout_items|tojson }};
|
||
let pluginConfigs = {{ plugin_configs|tojson }};
|
||
let widgetsMeta = {{ widgets|tojson }};
|
||
const gridCols = {{ grid.cols }};
|
||
const gridRows = {{ grid.rows }};
|
||
const sizePresets = {{ size_presets|tojson }};
|
||
|
||
function widgetMeta(name) {
|
||
return widgetsMeta.find(w => w.name === name) || { name, label: name, schema: [], defaults: {} };
|
||
}
|
||
|
||
// ============ Tabs ============
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${name}`));
|
||
if (name === 'plugins') renderPluginConfigs();
|
||
}
|
||
|
||
// ============ Layout Editor ============
|
||
function renderGrid() {
|
||
const cont = document.getElementById('gridPreview');
|
||
cont.innerHTML = '';
|
||
for (let i = 0; i < gridCols * gridRows; i++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'grid-cell';
|
||
cont.appendChild(cell);
|
||
}
|
||
layoutItems.forEach((it, idx) => {
|
||
const div = document.createElement('div');
|
||
const m = widgetMeta(it.plugin);
|
||
div.className = 'grid-item';
|
||
div.dataset.idx = idx;
|
||
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
|
||
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
|
||
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows;
|
||
if (hasOOB) div.classList.add('oob');
|
||
div.innerHTML = `
|
||
<div>
|
||
<div class="item-label">${m.label}</div>
|
||
<span class="item-category">${m.category}</span>
|
||
</div>
|
||
<div class="item-meta">${it.w}×${it.h} @ (${it.x},${it.y})</div>
|
||
<div class="item-controls">
|
||
<button class="icon" title="höher" onclick="event.stopPropagation(); moveItem(${idx}, 'up')">▲</button>
|
||
<button class="icon" title="tiefer" onclick="event.stopPropagation(); moveItem(${idx}, 'down')">▼</button>
|
||
<button class="icon" title="schmaler" onclick="event.stopPropagation(); resizeItem(${idx}, -1, 0)">◀</button>
|
||
<button class="icon" title="breiter" onclick="event.stopPropagation(); resizeItem(${idx}, 1, 0)">▶</button>
|
||
<button class="icon" title="kürzer" onclick="event.stopPropagation(); resizeItem(${idx}, 0, -1)">▴</button>
|
||
<button class="icon" title="länger" onclick="event.stopPropagation(); resizeItem(${idx}, 0, 1)">▾</button>
|
||
<button class="icon danger" title="löschen" onclick="event.stopPropagation(); removeItem(${idx})">✕</button>
|
||
</div>
|
||
`;
|
||
cont.appendChild(div);
|
||
});
|
||
updateLayoutStatus();
|
||
}
|
||
|
||
function updateLayoutStatus() {
|
||
const overlaps = findOverlaps(layoutItems);
|
||
const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows);
|
||
const status = document.getElementById('layoutStatus');
|
||
if (overlaps.length) {
|
||
status.textContent = `${overlaps.length} Überlappung(en)`;
|
||
status.classList.add('has-warnings');
|
||
} else if (oob.length) {
|
||
status.textContent = `${oob.length} außerhalb`;
|
||
status.classList.add('has-warnings');
|
||
} else {
|
||
status.textContent = `${layoutItems.length} items, OK`;
|
||
status.classList.remove('has-warnings');
|
||
}
|
||
document.getElementById('pluginCount').textContent = `${[...new Set(layoutItems.map(it => it.plugin))].length} Plugins konfiguriert`;
|
||
}
|
||
|
||
function findOverlaps(items) {
|
||
const out = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const a = items[i];
|
||
const ac = new Set();
|
||
for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy));
|
||
for (let j = i+1; j < items.length; j++) {
|
||
const b = items[j];
|
||
for (let dx = 0; dx < b.w; dx++) for (let dy = 0; dy < b.h; dy++) {
|
||
if (ac.has((b.x+dx)+','+(b.y+dy))) { out.push([i, j]); break; }
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function moveItem(idx, dir) {
|
||
const it = layoutItems[idx];
|
||
if (dir === 'up') it.y = Math.max(0, it.y - 1);
|
||
if (dir === 'down') it.y = Math.min(gridRows - it.h, it.y + 1);
|
||
renderGrid();
|
||
}
|
||
function resizeItem(idx, dw, dh) {
|
||
const it = layoutItems[idx];
|
||
it.w = Math.max(1, Math.min(gridCols - it.x, it.w + dw));
|
||
it.h = Math.max(1, Math.min(gridRows - it.y, it.h + dh));
|
||
renderGrid();
|
||
}
|
||
function removeItem(idx) {
|
||
if (!confirm('Item löschen?')) return;
|
||
layoutItems.splice(idx, 1);
|
||
renderGrid();
|
||
}
|
||
async function addItem(ev) {
|
||
ev.preventDefault();
|
||
const plugin = document.getElementById('newItemPlugin').value;
|
||
const sizeSel = document.getElementById('newItemSize').value;
|
||
const fd = new FormData();
|
||
fd.append('plugin', plugin);
|
||
if (sizeSel !== 'auto') {
|
||
const [w, h] = sizeSel.split('x').map(Number);
|
||
fd.append('w', w); fd.append('h', h);
|
||
}
|
||
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
}
|
||
async function saveLayout() {
|
||
const r = await fetch('/api/layout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ items: layoutItems }),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
setTimeout(refreshPreview, 500);
|
||
}
|
||
async function packAll() {
|
||
if (!confirm('Alle Items automatisch packen?')) return;
|
||
const r = await fetch('/api/layout/pack', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
setTimeout(refreshPreview, 500);
|
||
}
|
||
|
||
// ============ Plugin-Configs ============
|
||
function renderPluginConfigs() {
|
||
const cont = document.getElementById('pluginConfigs');
|
||
cont.innerHTML = '';
|
||
const usedPlugins = [...new Set(layoutItems.map(it => it.plugin))];
|
||
if (usedPlugins.length === 0) {
|
||
cont.innerHTML = '<div class="empty-state"><div class="icon">⚙</div>Keine Plugins im Layout. Füge im Layout-Tab Items hinzu.</div>';
|
||
return;
|
||
}
|
||
usedPlugins.forEach(pname => {
|
||
const m = widgetMeta(pname);
|
||
const cfg = pluginConfigs[pname] || {};
|
||
const card = document.createElement('div');
|
||
card.className = 'plugin-config-card';
|
||
card.dataset.plugin = pname;
|
||
let formFields = '';
|
||
for (const field of (m.schema || [])) {
|
||
const cur = cfg[field.key] !== undefined ? cfg[field.key] : (field.default !== undefined ? field.default : '');
|
||
const isSet = cur !== '' && cur !== null && cur !== undefined;
|
||
if (field.type === 'bool') {
|
||
formFields += `
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" name="config_${field.key}" ${cur ? 'checked' : ''} id="cfg_${pname}_${field.key}">
|
||
<label for="cfg_${pname}_${field.key}">${field.label}</label>
|
||
</div>`;
|
||
} else if (field.type === 'select') {
|
||
let opts = '';
|
||
for (const c of (field.choices || [])) {
|
||
opts += `<option value="${c}" ${cur === c ? 'selected' : ''}>${c}</option>`;
|
||
}
|
||
formFields += `<label>${field.label}</label><select name="config_${field.key}">${opts}</select>`;
|
||
} else if (field.type === 'secret') {
|
||
formFields += `
|
||
<label>${field.label}</label>
|
||
<div class="secret-row">
|
||
<input type="password" autocomplete="new-password" name="config_${field.key}"
|
||
placeholder="${isSet ? '•••••••• (gesetzt)' : 'Token / Secret einfügen'}">
|
||
${isSet ? `<button type="submit" name="config_${field.key}" value="" formaction="/api/plugin_config/${pname}" formmethod="post" title="Secret entfernen">✕</button>` : ''}
|
||
</div>`;
|
||
} else if (field.type === 'int' || field.type === 'float') {
|
||
formFields += `<label>${field.label}</label>
|
||
<input type="number" step="${field.type === 'float' ? '0.01' : '1'}" name="config_${field.key}" value="${cur}">`;
|
||
} else {
|
||
formFields += `<label>${field.label}</label>
|
||
<input type="text" name="config_${field.key}" value="${cur}">`;
|
||
}
|
||
if (field.help) formFields += `<div class="help">${field.help}</div>`;
|
||
}
|
||
card.innerHTML = `
|
||
<h3>${m.label} <span class="badge">${pname}</span></h3>
|
||
<div class="desc">${m.description || ''}</div>
|
||
<form data-plugin="${pname}" onsubmit="return savePluginConfig(event, '${pname}')">
|
||
${formFields}
|
||
<div class="actions">
|
||
<button type="submit" class="primary">Config speichern</button>
|
||
<span class="save-status" id="save_status_${pname}">Gespeichert</span>
|
||
</div>
|
||
</form>`;
|
||
cont.appendChild(card);
|
||
});
|
||
}
|
||
async function savePluginConfig(ev, pname) {
|
||
ev.preventDefault();
|
||
const fd = new FormData(ev.target);
|
||
const r = await fetch('/api/plugin_config/' + pname, { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
pluginConfigs[pname] = j.config;
|
||
const status = document.getElementById('save_status_' + pname);
|
||
if (status) {
|
||
status.classList.add('visible');
|
||
setTimeout(() => status.classList.remove('visible'), 2000);
|
||
}
|
||
setTimeout(refreshPreview, 500);
|
||
}
|
||
|
||
// ============ Refresh + Preview ============
|
||
function refreshPreview() {
|
||
document.getElementById('previewImg').src = '/snapshot.png?t=' + Date.now();
|
||
}
|
||
async function triggerRefresh() {
|
||
const btn = document.getElementById('refreshBtn');
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true; btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/></svg> läuft...';
|
||
const r = await fetch('/refresh', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.ipc_response === 'OK') {
|
||
setTimeout(() => { refreshPreview(); btn.disabled = false; btn.innerHTML = orig; }, 35000);
|
||
} else {
|
||
btn.disabled = false; btn.innerHTML = orig;
|
||
}
|
||
}
|
||
|
||
// ============ Network ============
|
||
async function netFetch(path, opts) {
|
||
const r = await fetch(path, opts);
|
||
if (r.status === 401) { location.reload(); return null; }
|
||
return r.json();
|
||
}
|
||
function fmtDuration(s) {
|
||
if (s < 60) return s + 's';
|
||
if (s < 3600) return Math.floor(s/60) + 'm';
|
||
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
||
}
|
||
function modeBadge(m) {
|
||
return {
|
||
client: { txt: 'ONLINE', color: 'online' },
|
||
ap: { txt: 'AP MODE', color: 'ap' },
|
||
connecting: { txt: 'CONNECTING', color: 'connecting' },
|
||
offline: { txt: 'OFFLINE', color: 'offline' },
|
||
unknown: { txt: 'UNKNOWN', color: '' },
|
||
}[m] || { txt: m.toUpperCase(), color: '' };
|
||
}
|
||
async function refreshNetStatus() {
|
||
const s = await netFetch('/api/network/status'); if (!s) return;
|
||
const b = modeBadge(s.mode);
|
||
const el = document.getElementById('netStatus');
|
||
el.className = 'net-status ' + b.color;
|
||
document.getElementById('netMode').textContent = b.txt;
|
||
document.getElementById('netSsid').textContent = s.ssid ? `"${s.ssid}"` : '';
|
||
document.getElementById('netIp').textContent = s.ip ? s.ip : '—';
|
||
document.getElementById('netError').textContent = s.error || '';
|
||
document.getElementById('netCurrent').innerHTML = `
|
||
<div>SSID: <strong>${s.ssid || '—'}</strong></div>
|
||
<div>IP: ${s.ip || '—'}</div>
|
||
<div>Signal: ${s.signal || 0}%</div>
|
||
<div>Ping: ${s.gateway_ping_ms || '—'} ms</div>
|
||
<div>Uptime: ${s.since_change_s != null ? fmtDuration(s.since_change_s) : '—'}</div>
|
||
`;
|
||
document.getElementById('netStopApBtn').style.display = (s.mode === 'ap') ? '' : 'none';
|
||
const hints = {
|
||
ap: 'Im AP-Modus mit "epaper-recovery" verbinden und WLAN neu konfigurieren.',
|
||
offline: 'Kein WLAN erreichbar. Force AP klicken.',
|
||
connecting: 'Verbindung wird aufgebaut, max 30s.',
|
||
client: 'Normalbetrieb.',
|
||
};
|
||
document.getElementById('netHint').textContent = hints[s.mode] || '';
|
||
}
|
||
async function refreshScan() {
|
||
const r = await netFetch('/api/network/scan'); if (!r) return;
|
||
const sel = document.getElementById('ssidSelect');
|
||
sel.innerHTML = '<option value="">— bitte wählen —</option>';
|
||
for (const n of r.networks) {
|
||
const opt = document.createElement('option');
|
||
opt.value = n.ssid;
|
||
opt.textContent = `${n.ssid} (${n.signal}%)`;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
async function refreshSaved() {
|
||
const r = await netFetch('/api/network/saved'); if (!r) return;
|
||
const ul = document.getElementById('savedList'); ul.innerHTML = '';
|
||
if (!r.saved.length) { ul.innerHTML = '<li class="meta">Keine Profile.</li>'; return; }
|
||
for (const s of r.saved) {
|
||
const li = document.createElement('li');
|
||
li.innerHTML = `<code>${s.name}</code>
|
||
<button class="ghost danger icon" onclick="netForget('${s.name}')" title="löschen">✕</button>`;
|
||
ul.appendChild(li);
|
||
}
|
||
}
|
||
async function netConnect() {
|
||
const manual = document.getElementById('ssidManual').value.trim();
|
||
const sel = document.getElementById('ssidSelect').value;
|
||
const ssid = document.getElementById('ssidManual').disabled ? sel : manual;
|
||
if (!ssid) { alert('SSID fehlt'); return; }
|
||
const fd = new FormData();
|
||
fd.append('ssid', ssid);
|
||
fd.append('password', document.getElementById('wifiPwd').value);
|
||
fd.append('security', document.getElementById('wifiSecurity').value);
|
||
const r = await netFetch('/api/network/connect', { method: 'POST', body: fd });
|
||
document.getElementById('netConnResult').textContent = r && r.ok ?
|
||
`Verbunden mit "${r.ssid}".` : 'Fehler: ' + (r && r.message || '?');
|
||
setTimeout(refreshNetStatus, 10000);
|
||
refreshSaved();
|
||
}
|
||
async function netReconnect() { await netFetch('/api/network/reconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
|
||
async function netDisconnect() { if (!confirm('Trennen?')) return; await netFetch('/api/network/disconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
|
||
async function netForceAp() { if (!confirm('AP starten?')) return; await netFetch('/api/network/ap/start', { method: 'POST' }); setTimeout(refreshNetStatus, 3000); }
|
||
async function netStopAp() { await netFetch('/api/network/ap/stop', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
|
||
async function netForget(name) { if (!confirm(`"${name}" löschen?`)) return; await netFetch('/api/network/forget', { method: 'POST', body: new URLSearchParams({ name }) }); refreshSaved(); }
|
||
|
||
// ============ Admin UI Theme Switcher ============
|
||
function setAdminTheme(theme) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
localStorage.setItem('adminTheme', theme);
|
||
document.querySelectorAll('.theme-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.adminTheme === theme);
|
||
});
|
||
}
|
||
// Restore saved admin theme on load
|
||
const savedTheme = localStorage.getItem('adminTheme');
|
||
if (savedTheme) setAdminTheme(savedTheme);
|
||
|
||
// ============ Display Design Switcher ============
|
||
const DESIGN_LABELS = {
|
||
minimal: "Minimal", minimal_color: "Minimal Color",
|
||
bold: "Bold", analog: "Analog Clock", magazine: "Magazine",
|
||
cards: "Cards", kiosk: "Kiosk", classic: "Classic",
|
||
};
|
||
|
||
async function loadDisplayDesign() {
|
||
const r = await fetch('/api/config');
|
||
if (!r.ok) return;
|
||
const cfg = await r.json();
|
||
const current = cfg.display_design || 'minimal';
|
||
document.querySelectorAll('.design-option').forEach(el => {
|
||
el.classList.toggle('selected', el.dataset.design === current);
|
||
});
|
||
const badge = document.getElementById('currentDesignBadge');
|
||
if (badge) badge.textContent = DESIGN_LABELS[current] || current;
|
||
}
|
||
|
||
async function setDisplayDesign(design) {
|
||
await fetch('/api/display_design', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({design}),
|
||
});
|
||
document.querySelectorAll('.design-option').forEach(el => {
|
||
el.classList.toggle('selected', el.dataset.design === design);
|
||
});
|
||
const badge = document.getElementById('currentDesignBadge');
|
||
if (badge) badge.textContent = DESIGN_LABELS[design] || design;
|
||
}
|
||
|
||
// ============ Init ============
|
||
renderGrid();
|
||
refreshNetStatus();
|
||
refreshScan();
|
||
refreshSaved();
|
||
loadDisplayDesign();
|
||
setInterval(refreshNetStatus, 10000);
|
||
setInterval(refreshScan, 60000);
|
||
setInterval(refreshPreview, 10000);
|
||
</script>
|
||
<style>
|
||
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
||
</style>
|
||
</body>
|
||
</html>
|