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
This commit is contained in:
ki
2026-08-26 14:41:34 +04:00
parent 99fb5692ef
commit a2c4158ba8
3 changed files with 228 additions and 17 deletions
+52
View File
@@ -337,6 +337,58 @@ def api_layout_pack():
return jsonify({"ok": True, "items": cfg["layout"]["items"]}) return jsonify({"ok": True, "items": cfg["layout"]["items"]})
DISPLAY_THEMES = {
"default": {
"label": "Classic White",
"bg": "#ffffff", "fg": "#000000",
"accent": "#ff8800",
},
"dark": {
"label": "Dark Mode",
"bg": "#111111", "fg": "#f0f0f0",
"accent": "#6d8eff",
},
"sepia": {
"label": "Sepia",
"bg": "#f4ecd8", "fg": "#5b4636",
"accent": "#c0392b",
},
"nord": {
"label": "Nord",
"bg": "#eceff4", "fg": "#2e3440",
"accent": "#88c0d0",
},
"terminal": {
"label": "Terminal (Green)",
"bg": "#0d0d00", "fg": "#e8c840",
"accent": "#ffcc00",
},
}
@app.route("/api/display_theme", methods=["GET", "POST"])
def api_display_theme():
"""Theme für das Display. GET = aktuelles Theme, POST = setzen."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
if request.method == "GET":
theme_key = cfg.get("display_theme", "default")
theme = DISPLAY_THEMES.get(theme_key, DISPLAY_THEMES["default"])
return jsonify({
"current": theme_key,
"themes": {k: v["label"] for k, v in DISPLAY_THEMES.items()},
"theme": theme,
})
data = request.get_json(silent=True) or {}
theme_key = data.get("theme", "default")
if theme_key not in DISPLAY_THEMES:
return jsonify({"ok": False, "error": "unknown theme"}), 400
cfg["display_theme"] = theme_key
dashboard_mod.save_config(cfg)
return jsonify({"ok": True, "current": theme_key, "theme": DISPLAY_THEMES[theme_key]})
@app.route("/api/plugin_config/<plugin_name>", methods=["GET", "POST"]) @app.route("/api/plugin_config/<plugin_name>", methods=["GET", "POST"])
def api_plugin_config(plugin_name): def api_plugin_config(plugin_name):
"""Plugin-spezifische Config (separat vom Layout).""" """Plugin-spezifische Config (separat vom Layout)."""
+44
View File
@@ -260,6 +260,46 @@ class Dashboard:
self.items = items self.items = items
self.widgets = widgets self.widgets = widgets
self.config_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0 self.config_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0
self.display_theme = cfg.get("display_theme", "default")
def apply_display_theme(self, img):
"""Überschreibe BG/FG des Bildes je nach display_theme."""
theme = self.display_theme if hasattr(self, 'display_theme') else "default"
if theme == "default":
return
# Map theme → palette overrides
# For now only BG/FG swap makes sense for ACeP
# dark/terminal invert, sepia/nord just shift
themed_palette = {
"dark": {"bg": (17, 17, 17), "fg": (240, 240, 240)},
"sepia": {"bg": (244, 236, 216), "fg": (91, 70, 54)},
"nord": {"bg": (236, 239, 244), "fg": (46, 52, 64)},
"terminal": {"bg": (13, 13, 0), "fg": (232, 200, 64)},
}
if theme not in themed_palette:
return
overrides = themed_palette[theme]
# Flood-fill BG areas with theme BG (simple but effective for flat designs)
# We do an Image.paste to recolor the BG — for now just swap BG pixel
bg_r, bg_g, bg_b = overrides["bg"]
fg_color = overrides["fg"]
# Replace WHITE pixels with theme BG, replace BLACK pixels with theme FG
data = img.convert("RGB")
w, h = img.size
# Direct pixel access — only for flat BG designs
for y in range(h):
for x in range(w):
r, g, b = data.getpixel((x, y))
if r > 240 and g > 240 and b > 240:
# WHITE → theme bg
data.putpixel((x, y), (bg_r, bg_g, bg_b))
elif r < 15 and g < 15 and b < 15:
# BLACK → theme fg
data.putpixel((x, y), fg_color)
# Convert back to palette-compatible mode
pal_img = img.copy()
pal_img.paste(data)
return pal_img
def maybe_reload_config(self): def maybe_reload_config(self):
if not CONFIG_PATH.exists(): if not CONFIG_PATH.exists():
@@ -288,6 +328,10 @@ class Dashboard:
items = list(self.items) items = list(self.items)
widgets = list(self.widgets) widgets = list(self.widgets)
img = render_full(items, widgets, self.fonts) img = render_full(items, widgets, self.fonts)
# Apply display theme (BG/FG color remap) — admin preview also uses this
themed = self.apply_display_theme(img)
if themed is not None:
return themed
return img return img
def display(self, img: Image.Image): def display(self, img: Image.Image):
+132 -17
View File
@@ -6,24 +6,36 @@
<title>epaper-dashboard</title> <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"> <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> <style>
:root { /* ================================================================
--bg: #0a0a0f; ADMIN UI THEME VARIABLES — switch via data-theme="A|B|C" on <body>
--surface: #15151c; ================================================================ */
--surface-2: #1f1f28; :root,
--surface-3: #2a2a35; :root[data-theme="A"] {
--border: #2e2e3a; /* A: Dark Minimal (original/default) */
--border-light: #3a3a48; --bg: #0a0a0f; --surface: #15151c; --surface-2: #1f1f28; --surface-3: #2a2a35;
--fg: #f0f0f5; --border: #2e2e3a; --border-light: #3a3a48;
--fg-muted: #9090a0; --fg: #f0f0f5; --fg-muted: #9090a0; --fg-dim: #6a6a78;
--fg-dim: #6a6a78; --accent: #6d8eff; --accent-glow: rgba(109,142,255,0.15);
--accent: #6d8eff; --ok: #4ade80; --warn: #fbbf24; --alert: #f87171; --info: #60a5fa; --success: #22c55e;
--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; } * { box-sizing: border-box; }
@@ -464,6 +476,29 @@
::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-light); } ::-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> </style>
</head> </head>
<body> <body>
@@ -494,6 +529,11 @@
<div class="mode-error" id="netError"></div> <div class="mode-error" id="netError"></div>
</div> </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()"> <button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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 12a9 9 0 1 1-3-6.7L21 8"/>
@@ -586,6 +626,36 @@
</div> </div>
</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">
<div class="card-header"> <div class="card-header">
<h2>System</h2> <h2>System</h2>
@@ -986,11 +1056,56 @@
async function netStopAp() { await netFetch('/api/network/ap/stop', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); } 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(); } 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 ============ // ============ Init ============
renderGrid(); renderGrid();
refreshNetStatus(); refreshNetStatus();
refreshScan(); refreshScan();
refreshSaved(); refreshSaved();
loadDisplayTheme();
setInterval(refreshNetStatus, 10000); setInterval(refreshNetStatus, 10000);
setInterval(refreshScan, 60000); setInterval(refreshScan, 60000);
setInterval(refreshPreview, 10000); setInterval(refreshPreview, 10000);