Files
epaper-dashboard/templates/index.html
T
ki a2c4158ba8 Add theme switchers: Admin UI (A/B/C) + Display (5 themes)
Admin UI: Dark Minimal / Retro Terminal / Warm Editorial via header buttons
Display: Classic White / Dark / Sepia / Nord / Terminal via Settings tab
Both switchers persist via localStorage / config.json
2026-08-26 14:41:34 +04:00

1118 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="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>
/* ================================================================
ADMIN UI THEME VARIABLES — switch via data-theme="A|B|C" on <body>
================================================================ */
:root,
:root[data-theme="A"] {
/* A: Dark Minimal (original/default) */
--bg: #0a0a0f; --surface: #15151c; --surface-2: #1f1f28; --surface-3: #2a2a35;
--border: #2e2e3a; --border-light: #3a3a48;
--fg: #f0f0f5; --fg-muted: #9090a0; --fg-dim: #6a6a78;
--accent: #6d8eff; --accent-glow: rgba(109,142,255,0.15);
--ok: #4ade80; --warn: #fbbf24; --alert: #f87171; --info: #60a5fa; --success: #22c55e;
}
:root[data-theme="B"] {
/* B: Retro Terminal — CRT green phosphor */
--bg: #0d0d00; --surface: #141400; --surface-2: #1a1a00; --surface-3: #222200;
--border: #2a2a00; --border-light: #3a3a00;
--fg: #e8c840; --fg-muted: #a09020; --fg-dim: #605800;
--accent: #ffcc00; --accent-glow: rgba(255,204,0,0.12);
--ok: #88ff44; --warn: #ffaa00; --alert: #ff4444; --info: #88ccff; --success: #44ff88;
}
:root[data-theme="C"] {
/* C: Warm Editorial — newspaper / cream paper */
--bg: #f4f1eb; --surface: #faf8f4; --surface-2: #f0ede6; --surface-3: #e8e4dc;
--border: #d8d3c8; --border-light: #e6e1d8;
--fg: #1a1816; --fg-muted: #6b6560; --fg-dim: #9a9490;
--accent: #c0392b; --accent-glow: rgba(192,57,43,0.10);
--ok: #27ae60; --warn: #e67e22; --alert: #c0392b; --info: #2980b9; --success: #27ae60;
}
:root[data-theme="B"] body { background-image: repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.06) 2px,rgba(0,0,0,0.06) 4px); }
* { box-sizing: border-box; }
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 Theme</h2>
</div>
<div style="display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; margin-bottom: 8px; font-size: 0.82em; color: var(--fg-muted);">
Theme für das e-Paper-Display
</label>
<select id="displayThemeSelect" onchange="setDisplayTheme(this.value)"
style="width: 100%; padding: 8px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9em;">
<option value="default" id="themeOpt_default">Classic White</option>
<option value="dark" id="themeOpt_dark">Dark Mode</option>
<option value="sepia" id="themeOpt_sepia">Sepia</option>
<option value="nord" id="themeOpt_nord">Nord</option>
<option value="terminal" id="themeOpt_terminal">Terminal (Green)</option>
</select>
</div>
<div id="themePreviewBox" style="display: flex; gap: 10px; align-items: center; padding-top: 22px;">
<div style="text-align: center;">
<div id="themePreview" style="width: 80px; height: 48px; border-radius: 4px; border: 1px solid var(--border); background: #fff; display: flex; align-items: center; justify-content: center; font-size: 0.65em; color: #000;">Preview</div>
<div style="font-size: 0.65em; color: var(--fg-dim); margin-top: 4px;" id="themePreviewLabel">Classic</div>
</div>
</div>
</div>
<div class="meta" style="margin-top: 12px;">
Theme wird beim nächsten Refresh auf dem Display angezeigt. Nur für Designs mit weißem Hintergrund geeignet (Widget-Farben bleiben gleich).
</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 &amp; 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 Theme Switcher ============
const THEME_PREVIEWS = {
default: { bg: '#ffffff', fg: '#000000', label: 'Classic' },
dark: { bg: '#111111', fg: '#f0f0f0', label: 'Dark' },
sepia: { bg: '#f4ecd8', fg: '#5b4636', label: 'Sepia' },
nord: { bg: '#eceff4', fg: '#2e3440', label: 'Nord' },
terminal: { bg: '#0d0d00', fg: '#e8c840', label: 'Terminal' },
};
function updateThemePreview(theme) {
const p = THEME_PREVIEWS[theme] || THEME_PREVIEWS.default;
const el = document.getElementById('themePreview');
const lbl = document.getElementById('themePreviewLabel');
if (el) { el.style.background = p.bg; el.style.color = p.fg; }
if (lbl) lbl.textContent = p.label;
}
async function loadDisplayTheme() {
const r = await fetch('/api/display_theme');
if (!r.ok) return;
const d = await r.json();
document.getElementById('displayThemeSelect').value = d.current;
updateThemePreview(d.current);
}
async function setDisplayTheme(theme) {
updateThemePreview(theme);
const r = await fetch('/api/display_theme', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({theme})
});
if (r.ok) triggerRefresh();
}
// ============ Init ============
renderGrid();
refreshNetStatus();
refreshScan();
refreshSaved();
loadDisplayTheme();
setInterval(refreshNetStatus, 10000);
setInterval(refreshScan, 60000);
setInterval(refreshPreview, 10000);
</script>
<style>
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
</style>
</body>
</html>