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:
@@ -337,6 +337,58 @@ def api_layout_pack():
|
||||
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"])
|
||||
def api_plugin_config(plugin_name):
|
||||
"""Plugin-spezifische Config (separat vom Layout)."""
|
||||
|
||||
@@ -260,6 +260,46 @@ class Dashboard:
|
||||
self.items = items
|
||||
self.widgets = widgets
|
||||
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):
|
||||
if not CONFIG_PATH.exists():
|
||||
@@ -288,6 +328,10 @@ class Dashboard:
|
||||
items = list(self.items)
|
||||
widgets = list(self.widgets)
|
||||
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
|
||||
|
||||
def display(self, img: Image.Image):
|
||||
|
||||
+132
-17
@@ -6,24 +6,36 @@
|
||||
<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>
|
||||
:root {
|
||||
--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;
|
||||
/* ================================================================
|
||||
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; }
|
||||
|
||||
@@ -464,6 +476,29 @@
|
||||
::-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>
|
||||
@@ -494,6 +529,11 @@
|
||||
<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"/>
|
||||
@@ -586,6 +626,36 @@
|
||||
</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>
|
||||
@@ -986,11 +1056,56 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user