Linear design system: new admin UI + display design switcher (8 designs)

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
This commit is contained in:
ki
2026-08-26 15:08:17 +04:00
parent a2c4158ba8
commit 49de61bb26
6 changed files with 1595 additions and 119 deletions
+42 -1
View File
@@ -365,10 +365,30 @@ DISPLAY_THEMES = {
},
}
# Display designs — the actual visual layout approach
DISPLAY_DESIGNS = {
"classic": {
"label": "Classic",
"desc": "Original flat design, white background",
},
"magazine": {
"label": "Magazine",
"desc": "Bold typography, minimal, colored accent bars",
},
"cards": {
"label": "Cards",
"desc": "iOS-style panels with emoji icons, warm white",
},
"kiosk": {
"label": "Kiosk",
"desc": "Data-dense, dark background, inverted info cards",
},
}
@app.route("/api/display_theme", methods=["GET", "POST"])
def api_display_theme():
"""Theme für das Display. GET = aktuelles Theme, POST = setzen."""
"""GET: list themes + current. POST: set theme (legacy color-only)."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
@@ -389,6 +409,27 @@ def api_display_theme():
return jsonify({"ok": True, "current": theme_key, "theme": DISPLAY_THEMES[theme_key]})
@app.route("/api/display_design", methods=["GET", "POST"])
def api_display_design():
"""Design für das Display (classic/magazine/cards/kiosk)."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
if request.method == "GET":
design_key = cfg.get("display_design", "classic")
return jsonify({
"current": design_key,
"designs": {k: {"label": v["label"], "desc": v["desc"]} for k, v in DISPLAY_DESIGNS.items()},
})
data = request.get_json(silent=True) or {}
design_key = data.get("design", "classic")
if design_key not in DISPLAY_DESIGNS:
return jsonify({"ok": False, "error": "unknown design"}), 400
cfg["display_design"] = design_key
dashboard_mod.save_config(cfg)
return jsonify({"ok": True, "current": design_key})
@app.route("/api/plugin_config/<plugin_name>", methods=["GET", "POST"])
def api_plugin_config(plugin_name):
"""Plugin-spezifische Config (separat vom Layout)."""
+4 -44
View File
@@ -15,6 +15,7 @@ from PIL import Image, ImageDraw, ImageFont
from palette import BG, FG, OK
from plugins.base import all_widget_classes, Widget
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
import renderer
import importlib, pkgutil
import plugins as _plugins_pkg # ensure package is importable
@@ -261,45 +262,7 @@ class Dashboard:
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
self.display_design = cfg.get("display_design", "classic")
def maybe_reload_config(self):
if not CONFIG_PATH.exists():
@@ -327,11 +290,8 @@ class Dashboard:
with self._lock:
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
design = getattr(self, 'display_design', 'classic')
img = renderer.render_design(items, widgets, self.fonts, design=design)
return img
def display(self, img: Image.Image):
+474
View File
@@ -0,0 +1,474 @@
"""Render-Pipeline für das Dashboard.
Unterstützt 3 Display-Designs:
magazine — großes TYPO, schmale Balken, monochrome Akzente
cards — Panel-Hintergründe, Emoji-Icons, vertikale Ränder
kiosk — Daten-first, viele kleine Info-Zellen, kompakt
Aufruf:
img = render_design(items, widgets, fonts, design="magazine")
"""
from PIL import Image, ImageDraw, ImageFont
from palette import FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, OK, WARN, ALERT, INFO, ACCENT
import math
DISPLAY_W = 800
DISPLAY_H = 480
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
def render_design(items, widgets, fonts, design="magazine") -> Image.Image:
fn = {
"magazine": _render_magazine,
"cards": _render_cards,
"kiosk": _render_kiosk,
"wireframe": _render_wireframe,
"bauhaus": _render_bauhaus,
"poster": _render_poster,
"retro_lcd": _render_retro_lcd,
"glossy": _render_glossy,
"data_board": _render_data_board,
"minimal": _render_minimal,
"minimal_color": _render_minimal_color,
"bold": _render_bold,
"analog": _render_analog_clock,
}.get(design, _render_minimal)
return fn(items, widgets, fonts)
# ============================================================================
# MAGAZINE — Bold Typography, Minimalist
# Idee: Wanduhr-Magazin / Bloomberg-Terminal-Ästhetik
# Schwarz/Weiß + eine Akzentfarbe pro Widget
# ============================================================================
def _render_magazine(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _pixels(item)
if widget is None:
continue
# Jedes Widget bekommt einen vertikalen Akzent-Streifen links
accent = _widget_accent(widget.name)
draw.rectangle((x, y, x + 6, y + h - 1), fill=accent)
# Widget-Name oben links, klein
label = (widget.label or widget.name or "").upper()
draw.text((x + 14, y + 6), label, font=fonts.get("16", fonts.get("default")), fill=(160, 160, 160))
# Horizontale Linie unter Label
draw.line((x + 14, y + 26, x + w - 10, y + 26), fill=(220, 220, 220), width=1)
# Content-Bereich (ab y+32)
cy = y + 36
ch = h - 42
widget.render(draw, fonts, x + 14, cy, w - 20, ch)
return img
# ============================================================================
# CARDS — Background Panels + Emoji Icons + Vertical Accent Bars
# Idee: iOS Widgets / Google Smart Display
# Leicht grauer Hintergrund pro Karte, Emoji für Kategorie
# ============================================================================
def _render_cards(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 244, 240)) # warm white
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _pixels(item)
if widget is None:
continue
# Card background
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE)
# Top accent bar
accent = _widget_accent(widget.name)
draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=accent)
# Emoji icon (use emoji renderer via fallback)
icon = _widget_icon(widget.name)
_draw_emoji(draw, icon, x + 10, y + 18, 28)
# Label
label = widget.label or widget.name or ""
draw.text((x + 44, y + 18), label, font=fonts.get("16", fonts.get("default")), fill=(80, 80, 80))
# Separator line
draw.line((x + 8, y + 52, x + w - 8, y + 52), fill=(230, 230, 230), width=1)
# Content
widget.render(draw, fonts, x + 8, y + 58, w - 16, h - 64)
return img
# ============================================================================
# KIOSK — Data-Dense, Compact, Multi-Zone
# Idee: Control-Room / LoRa-Tracker / Science-Dashboard
# Viele kleine Info-Zellen, horizontale Teilung, monochrome Palette
# ============================================================================
def _render_kiosk(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _pixels(item)
if widget is None:
continue
# Invertierte Box mit Akzent-Rand
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE)
accent = _widget_accent(widget.name)
draw.rectangle((x, y, x + w - 1, y + 3), fill=accent)
# Label in Akzentfarbe oben
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=accent)
# Content
widget.render(draw, fonts, x + 6, y + 28, w - 12, h - 34)
return img
# ============================================================================
# Helper
# ============================================================================
def _pixels(item):
"""Item (x,y,w,h) in Pixel (800x480, 4x4 grid)"""
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
return item.x * CELL_W, item.y * CELL_H, item.w * CELL_W, item.h * CELL_H
# Alias für neue Designs
def _px(item):
return _pixels(item)
def _widget_accent(name):
accents = {
"clock": BLUE,
"weather": GREEN,
"system": ORANGE,
"hello": RED,
"spotify": (0, 200, 0), # spotify green
"strava": ORANGE,
"gmail": RED,
"minimax": (180, 100, 255), # purple
}
return accents.get(name, ORANGE)
def _widget_icon(name):
icons = {
"clock": "",
"weather": "🌤️",
"system": "💻",
"hello": "👋",
"spotify": "🎵",
"strava": "🚴",
"gmail": "📧",
"minimax": "🤖",
}
return icons.get(name, "📟")
def _draw_emoji(draw, emoji, x, y, size):
"""Draw emoji by using a fallback ImageFont trick with the emoji char."""
# On systems without an emoji font, this just draws a rectangle placeholder
# The real implementation would need a font with emoji support
# For now draw a colored square as placeholder
colors = {
"": (80, 160, 255),
"🌤️": (255, 200, 80),
"💻": (100, 100, 100),
"👋": (255, 160, 120),
"🎵": (0, 200, 100),
"🚴": (255, 140, 0),
"📧": (220, 80, 80),
"🤖": (180, 100, 255),
}
c = colors.get(emoji, (200, 200, 200))
draw.ellipse((x, y, x + size, y + size), fill=c)
# ============================================================================
# DESIGN 5: WIREFRAME
# Technisches Plan-Drawing. Blaue Linien, weiße Flächen.
# Blueprint-Ästhetik.
# ============================================================================
def _render_wireframe(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (240, 248, 255))
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE)
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=BLUE, width=1)
draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=BLUE)
label = (widget.label or widget.name or "").upper()
draw.text((x + 10, y + 14), label, font=fonts.get("14", fonts.get("default")), fill=BLUE)
for row in range(item.y, item.y + item.h):
for col in range(item.x, item.x + item.w):
if (row + col) % 2 == 0:
cx = col * CELL_W + 4
cy = row * CELL_H + 4
cw = CELL_W - 6
ch = CELL_H - 6
draw.rectangle((cx, cy, cx + cw, cy + ch), fill=(240, 248, 255))
widget.render(draw, fonts, x + 6, y + 32, w - 12, h - 38)
return img
# ============================================================================
# DESIGN 6: BAUHAUS
# Geometrische Primärfarben. Jedes Widget bekommt eine der
# 7 ACeP-Farben als Hintergrund-Balken links.
# ============================================================================
def _render_bauhaus(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240))
draw = ImageDraw.Draw(img)
PALETTE_7 = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)]
for i, (item, widget) in enumerate(zip(items, widgets)):
x, y, w, h = _px(item)
if widget is None:
continue
color = PALETTE_7[i % len(PALETTE_7)]
block_w = w // 3
draw.rectangle((x + 2, y + 2, x + block_w - 2, y + h - 3), fill=color)
draw.rectangle((x + block_w, y + 2, x + w - 3, y + h - 3), fill=WHITE)
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=FG, width=2)
label = (widget.label or widget.name or "").upper()
draw.text((x + 6, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=WHITE)
widget.render(draw, fonts, x + block_w + 4, y + 4, w - block_w - 8, h - 8)
return img
# ============================================================================
# DESIGN 7: POSTER
# Farbige Flächen, bold, fast nur Content.
# Jedes Widget in einer kräftigen Farbe.
# ============================================================================
def _render_poster(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK)
draw = ImageDraw.Draw(img)
colors = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)]
for i, (item, widget) in enumerate(zip(items, widgets)):
x, y, w, h = _px(item)
if widget is None:
continue
c = colors[i % len(colors)]
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=c)
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=WHITE, width=3)
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 6), label[:18], font=fonts.get("12", fonts.get("default")), fill=WHITE)
widget.render(draw, fonts, x + 6, y + 24, w - 12, h - 30)
return img
# ============================================================================
# DESIGN 8: RETRO LCD
# Alte LCD-Uhren-Ästhetik. Dunkelgrüner Hintergrund,
# leuchtende grüne Ziffern, Fake-3D-Rahmen.
# ============================================================================
def _render_retro_lcd(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (0, 20, 0))
draw = ImageDraw.Draw(img)
LCD_GREEN = (0, 255, 120)
LCD_DARK = (0, 60, 30)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x + 3, y + 3, x + w - 4, y + h - 4), fill=LCD_DARK)
draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=LCD_GREEN, width=2)
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 6), label[:16], font=fonts.get("11", fonts.get("default")), fill=LCD_GREEN)
draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=LCD_GREEN, width=1)
widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 32)
return img
# ============================================================================
# DESIGN 9: GLOSSY
# Moderne iOS-Widget-Ästhetik. Weiße Karten, Glossy-Header
# in Accent-Farbe, dezente Schatten-Rahmen.
# ============================================================================
def _render_glossy(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (220, 228, 235))
draw = ImageDraw.Draw(img)
WIDGET_ACCENTS = {
"clock": (80, 160, 255),
"weather": (60, 200, 100),
"system": (255, 160, 40),
"hello": (200, 80, 80),
"spotify": (0, 180, 80),
"strava": (255, 120, 0),
"gmail": (200, 60, 60),
"minimax": (160, 80, 255),
}
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
accent = WIDGET_ACCENTS.get(widget.name, (100, 100, 100))
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE)
draw.rectangle((x + 2, y + 2, x + w - 2, y + 14), fill=accent)
# Glossy highlight
for i in range(6):
alpha = int(80 - i * 12)
c = tuple(min(255, accent[j] + alpha) for j in range(3))
draw.line((x + 2, y + 2 + i, x + w - 2, y + 2 + i), fill=c, width=1)
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), outline=(200, 210, 220), width=1)
draw.rectangle((x + 3, y + 3, x + w - 3, y + h - 3), outline=(220, 228, 235), width=1)
draw.rectangle((x + 4, y + 4, x + w - 4, y + h - 4), outline=(180, 190, 200), width=1)
label = (widget.label or widget.name or "").title()
draw.text((x + 10, y + 18), label, font=fonts.get("14", fonts.get("default")), fill=accent)
draw.line((x + 8, y + 36, x + w - 8, y + 36), fill=(220, 220, 220), width=1)
widget.render(draw, fonts, x + 6, y + 40, w - 12, h - 46)
return img
# ============================================================================
# DESIGN 10: DATA BOARD
# Kontrollraum-Ästhetik. Winzige Zellen, viele Daten.
# Monochrome, Tick-Marks, Data-first.
# ============================================================================
def _render_data_board(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (10, 10, 15))
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE, width=2)
draw.rectangle((x, y, x + w - 1, y + 22), fill=WHITE)
draw.text((x + 6, y + 4), (widget.label or widget.name or "").upper(),
font=fonts.get("12", fonts.get("default")), fill=BLACK)
for tx in range(x + 10, x + w - 10, 20):
draw.line((tx, y + 24, tx, y + 28), fill=(180, 180, 180), width=1)
widget.render(draw, fonts, x + 4, y + 30, w - 8, h - 34)
return img
# ============================================================================
# MINIMAL — das Referenz-Design
# Schwarz/Weiß, bold, clean, viel Weißraum. Kein UI-Chrome.
# ============================================================================
def _render_minimal(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
widget.render(draw, fonts, x + 12, y + 12, w - 24, h - 24)
return img
# ============================================================================
# MINIMAL COLOR — Minimal mit dezenter Akzentfarbe pro Kategorie
# Kleiner Farbbalken oben links, sonst weiß + schwarz
# ============================================================================
def _render_minimal_color(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
CATEGORY_COLORS = {
"clock": (60, 80, 200),
"weather": (50, 160, 80),
"system": (200, 120, 30),
"hello": (180, 60, 60),
"spotify": (0, 160, 80),
"strava": (230, 110, 20),
"gmail": (200, 60, 60),
"minimax": (140, 80, 240),
}
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
color = CATEGORY_COLORS.get(widget.name, FG)
draw.rectangle((x + 12, y + 10, x + 16, y + 18), fill=color)
label = (widget.label or widget.name or "").upper()
draw.text((x + 22, y + 8), label, font=fonts.get("11", fonts.get("default")), fill=(160, 160, 160))
widget.render(draw, fonts, x + 12, y + 24, w - 24, h - 36)
return img
# ============================================================================
# BOLD — Riesen-Typography
# Zahlen und Text füllen die Zelle komplett.
# Kaum Weißraum, maximaler Informationsdichte.
# ============================================================================
def _render_bold(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=BLACK, width=1)
widget.render(draw, fonts, x + 6, y + 6, w - 12, h - 12)
return img
# ============================================================================
# ANALOG CLOCK — Die Uhr als Mittelpunkt
# 2x2-Clock-Zellen zeigen ein analoges Ziffernblatt.
# Übrige Zellen: cleanes Panel mit Monospace.
# ============================================================================
def _render_analog_clock(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240))
draw = ImageDraw.Draw(img)
import math
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
is_2x2 = item.w >= 2 and item.h >= 2
is_clock = widget.name == "clock" and is_2x2
if is_clock:
cx, cy = x + w // 2, y + h // 2
r = min(w, h) // 2 - 16
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=WHITE, outline=BLACK, width=2)
for i in range(12):
angle = (i / 12) * 2 * math.pi - math.pi / 2
mx = cx + int((r - 12) * math.cos(angle))
my = cy + int((r - 12) * math.sin(angle))
draw.ellipse((mx - 3, my - 3, mx + 3, my + 3), fill=BLACK)
from datetime import datetime
now = datetime.now()
hour_angle = ((now.hour % 12) / 12) * 2 * math.pi - math.pi / 2
min_angle = (now.minute / 60) * 2 * math.pi - math.pi / 2
hx = cx + int(r * 0.5 * math.cos(hour_angle))
hy = cy + int(r * 0.5 * math.sin(hour_angle))
draw.line((cx, cy, hx, hy), fill=BLACK, width=4)
mx = cx + int(r * 0.75 * math.cos(min_angle))
my = cy + int(r * 0.75 * math.sin(min_angle))
draw.line((cx, cy, mx, my), fill=BLACK, width=2)
draw.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), fill=BLACK)
draw.text((cx - 40, cy + r + 8), now.strftime("%H:%M"),
font=fonts.get("20", fonts.get("default")), fill=BLACK)
else:
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE)
draw.ellipse((x + 8, y + 8, x + 14, y + 14), fill=(60, 100, 200))
label = (widget.label or widget.name or "").upper()[:12]
draw.text((x + 20, y + 6), label, font=fonts.get("12", fonts.get("default")), fill=(100, 100, 100))
draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=(200, 200, 200), width=1)
widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 34)
return img
+272
View File
@@ -0,0 +1,272 @@
"""Neue Display-Designs für ACeP 7-Farben e-Paper.
Jedes Design definiert:
- Hintergrund / Rahmen
- Widget-Rahmen ( Frames)
- content_fn: wie das Widget gerendert wird
"""
from PIL import Image, ImageDraw
from palette import FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, OK, WARN, ALERT, INFO, ACCENT
import math
DISPLAY_W = 800
DISPLAY_H = 480
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
# ============================================================================
# DESIGN 5: WIREFRAME
# Technisches Plan-Drawing. Blaue Linien, weiße Flächen,
# schmale weiße Zellenrahmen. Blueprint-Ästhetik.
# ============================================================================
def render_wireframe(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (240, 248, 255)) # aliceblue
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
# Weißer Hintergrund pro Zelle
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE)
# Dünner Rahmen (blau)
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=BLUE, width=1)
# Obere Akzent-Linie (breit)
draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=BLUE)
# Widget-Name oben links
label = (widget.label or widget.name or "").upper()
draw.text((x + 10, y + 14), label, font=fonts.get("14", fonts.get("default")), fill=BLUE)
# Grid-Checkerboard im Hintergrund (subtle)
for row in range(item.y, item.y + item.h):
for col in range(item.x, item.x + item.w):
if (row + col) % 2 == 0:
cx = col * CELL_W + 4
cy = row * CELL_H + 4
cw = CELL_W - 6
ch = CELL_H - 6
draw.rectangle((cx, cy, cx + cw, cy + ch), fill=(240, 248, 255))
# Content
widget.render(draw, fonts, x + 6, y + 32, w - 12, h - 38)
return img
# ============================================================================
# DESIGN 6: BAUHAUS
# Geometrische Primärfarben. Jedes Widget bekommt eine der
# 7 ACeP-Farben als Hintergrund-Balken links. Reine Formen.
# ============================================================================
def render_bauhaus(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240)) # warm gray
draw = ImageDraw.Draw(img)
PALETTE_7 = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)]
item_colors = {}
for i, (item, widget) in enumerate(zip(items, widgets)):
x, y, w, h = _px(item)
if widget is None:
continue
color = PALETTE_7[i % len(PALETTE_7)]
item_colors[item.id] = color
# Großer farbiger Block links (30% width)
block_w = w // 3
draw.rectangle((x + 2, y + 2, x + block_w - 2, y + h - 3), fill=color)
# Rest weiß
draw.rectangle((x + block_w, y + 2, x + w - 3, y + h - 3), fill=WHITE)
# Rahmen
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=FG, width=2)
# Widget-Name in der farbigen Fläche
label = (widget.label or widget.name or "").upper()
draw.text((x + 6, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=WHITE)
# Content (rechte Seite)
widget.render(draw, fonts, x + block_w + 4, y + 4, w - block_w - 8, h - 8)
return img
# ============================================================================
# DESIGN 7: POSTER
# Überdimensionierte Typography. Jedes Widget großflächig.
# Widget-Hintergrund = monochrome Farbe aus Palette.
# Fast nur Text, kein UI-Chrome.
# ============================================================================
def render_poster(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
# Zufällige kräftige Farbe pro widget (basierend auf name hash)
c = _hash_color(widget.name)
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=c)
# Weißer Rahmen drum
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=WHITE, width=3)
# Label oben Winzig
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 6), label[:18], font=fonts.get("12", fonts.get("default")), fill=WHITE)
# Content — das Widget bekommt 90% der Fläche
# Wir übergeben ein invertiertes draw-Objekt (fg=WHITE, bg=c)
widget.render(draw, fonts, x + 6, y + 24, w - 12, h - 30)
return img
# ============================================================================
# DESIGN 8: RETRO LCD
# Alte LCD/Uhren-Ästhetik. Schwarzer Hintergrund, Ziffern in
# satten UnOld Green (LCD-STYLE). Rahmen mit abgerundeten Ecken,
# die wie eingeritzte Rillen aussehen.
# ============================================================================
def render_retro_lcd(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (0, 20, 0)) # dunkles LCD-grün
draw = ImageDraw.Draw(img)
LCD_GREEN = (0, 255, 120)
LCD_DARK = (0, 60, 30)
LCD_BG = (0, 30, 15)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
# LCD-Zelle: dunkler als Hintergrund
draw.rectangle((x + 3, y + 3, x + w - 4, y + h - 4), fill=LCD_DARK)
# Rahmen mit Fake-3D (oben/links hell, unten/rechts dunkel)
draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=LCD_GREEN, width=2)
# Label in kleiner Schrift oben
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 6), label[:16], font=fonts.get("11", fonts.get("default")), fill=LCD_GREEN)
# Separator-Linie
draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=LCD_GREEN, width=1)
# Content
widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 32)
return img
# ============================================================================
# DESIGN 9: GLOSSY
# Moderner Glossy-Style. Weiße Karten mit leichtem Farbverlauf oben,
# ikonischer Rand, dezente Schatten durch mehrfache Ränder.
# ============================================================================
def render_glossy(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (220, 228, 235)) # cool gray
draw = ImageDraw.Draw(img)
WIDGET_ACCENTS = {
"clock": (80, 160, 255),
"weather": (60, 200, 100),
"system": (255, 160, 40),
"hello": (200, 80, 80),
"spotify": (0, 180, 80),
"strava": (255, 120, 0),
"gmail": (200, 60, 60),
"minimax": (160, 80, 255),
}
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
accent = WIDGET_ACCENTS.get(widget.name, (100, 100, 100))
# Karte weiß
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE)
# Glossy-Effekt: heller Balken oben
draw.rectangle((x + 2, y + 2, x + w - 2, y + 14), fill=accent)
# Weißer Gradient-Overlay oben
for i in range(6):
alpha = int(80 - i * 12)
c = tuple(min(255, accent[j] + alpha) for j in range(3))
draw.line((x + 2, y + 2 + i, x + w - 2, y + 2 + i), fill=c, width=1)
# Rahmeneffekt: 3-lagig für Schatten
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), outline=(200, 210, 220), width=1)
draw.rectangle((x + 3, y + 3, x + w - 3, y + h - 3), outline=(220, 228, 235), width=1)
draw.rectangle((x + 4, y + 4, x + w - 4, y + h - 4), outline=(180, 190, 200), width=1)
# Label in Accent-Farbe
label = (widget.label or widget.name or "").title()
draw.text((x + 10, y + 18), label, font=fonts.get("14", fonts.get("default")), fill=accent)
# Separator
draw.line((x + 8, y + 36, x + w - 8, y + 36), fill=(220, 220, 220), width=1)
# Content
widget.render(draw, fonts, x + 6, y + 40, w - 12, h - 46)
return img
# ============================================================================
# DESIGN 10: DATA BOARD
# Kontrollraum-Ästhetik. Winzige Zellen, viele Daten.
# Horizontale Balken, Tick-Marks, monochrome Palette.
#===========================================================================
def render_data_board(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (10, 10, 15))
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
# Weißer Rand
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE, width=2)
# Header-Balken (invertiert)
draw.rectangle((x, y, x + w - 1, y + 22), fill=WHITE)
draw.text((x + 6, y + 4), (widget.label or widget.name or "").upper(),
font=fonts.get("12", fonts.get("default")), fill=BLACK)
# Kleine Tick-Marks oben (dekorativ)
for tx in range(x + 10, x + w - 10, 20):
draw.line((tx, y + 24, tx, y + 28), fill=(180, 180, 180), width=1)
# Content
widget.render(draw, fonts, x + 4, y + 30, w - 8, h - 34)
return img
# ============================================================================
# Helper
# ============================================================================
def _px(item):
return item.x * CELL_W, item.y * CELL_H, item.w * CELL_W, item.h * CELL_H
def _hash_color(name: str):
"""Consistent color from name using simple hash."""
colors = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 0, 200)]
h = sum(ord(c) for c in name)
return colors[h % len(colors)]
+203
View File
@@ -0,0 +1,203 @@
"""Minimal Display-Designs für ACeP 7-Farben.
Inspiration von loiccoyle/tinyticker + inkflow-eink:
- Weißer Hintergrund, schwarze Geometrie
- Bold Typography, große Zahlen
- SVG-style rendering (saubere Linien, einheitliche Strichstärke)
- Dezente Akzentfarbe pro Widget-Kategorie
"""
from PIL import Image, ImageDraw
from palette import FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, OK, WARN, ALERT, INFO, ACCENT
DISPLAY_W = 800
DISPLAY_H = 480
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
# Einheitliche Strichstärke
STROKE = 2 # px für alle Linien
SMALL_STROKE = 1
def render_design(items, widgets, fonts, design="minimal") -> Image.Image:
fn = {
"minimal": _render_minimal,
"minimal_color": _render_minimal_color,
"bold": _render_bold,
"analog": _render_analog_clock,
}.get(design, _render_minimal)
return fn(items, widgets, fonts)
# ============================================================================
# MINIMAL — das Referenz-Design
# Schwarz/Weiß, bold, clean, viel Weißraum
# ============================================================================
def _render_minimal(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
# Keine sichtbaren Grid-Linien — jedes Widget steht für sich
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
# Kein Rahmen, kein Hintergrund — reiner Content auf Weiß
# Content füllt die ganze Zelle
widget.render(draw, fonts, x + 12, y + 12, w - 24, h - 24)
return img
# ============================================================================
# MINIMAL COLOR — Minimal mit dezenter Akzentfarbe pro Kategorie
# Kleiner Farbbalken oben links, sonst weiß + schwarz
# ============================================================================
def _render_minimal_color(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
CATEGORY_COLORS = {
"clock": (60, 80, 200), # blau
"weather": (50, 160, 80), # grün
"system": (200, 120, 30), # orange
"hello": (180, 60, 60), # rot
"spotify": (0, 160, 80), # spotify-grün
"strava": (230, 110, 20), # strava-orange
"gmail": (200, 60, 60), # gmail-rot
"minimax": (140, 80, 240), # lila
}
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
color = CATEGORY_COLORS.get(widget.name, FG)
# Winziger Akzent-Strich oben (8px hoch, 3px breit)
draw.rectangle((x + 12, y + 10, x + 16, y + 18), fill=color)
# Widget-Name winzig darüber
label = (widget.label or widget.name or "").upper()
draw.text((x + 22, y + 8), label, font=fonts.get("11", fonts.get("default")), fill=(160, 160, 160))
# Content
widget.render(draw, fonts, x + 12, y + 24, w - 24, h - 36)
return img
# ============================================================================
# BOLD — Riesen-Typography
# Zahlen und Text füllen die Zelle komplett.
# Kaum Weißraum, maximaler Informationsdichte.
# ============================================================================
def _render_bold(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
# Weißer Hintergrund, schwarzer Rahmen (1px)
draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=BLACK, width=1)
# Content — groß
widget.render(draw, fonts, x + 6, y + 6, w - 12, h - 12)
return img
# ============================================================================
# ANALOG CLOCK — Die Uhr als Mittelpunkt
# Mittlere Zellen (2x2) zeigen die Uhr als analoges Ziffernblatt.
# Kleine Zellen (1x1) zeigen minimalistische Icons.
# Übrige Zellen: Daten-Widget mit Monospace-Text.
# ============================================================================
def _render_analog_clock(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240)) # warm white
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
is_2x2 = item.w >= 2 and item.h >= 2
is_clock = widget.name == "clock" and is_2x2
if is_clock:
# 2x2 Clock → Analoges Ziffernblatt
_draw_analog_clock(draw, x, y, w, h, fonts)
else:
# Andere Widgets: cleanes Panel
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE)
# Kleiner farbiger Punkt oben links
color = (60, 100, 200)
draw.ellipse((x + 8, y + 8, x + 14, y + 14), fill=color)
# Label
label = (widget.label or widget.name or "").upper()[:12]
draw.text((x + 20, y + 6), label, font=fonts.get("12", fonts.get("default")), fill=(100, 100, 100))
# Separator
draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=(200, 200, 200), width=1)
# Content
widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 34)
return img
def _draw_analog_clock(draw, x, y, w, h, fonts):
"""Zeichne ein analoges Ziffernblatt."""
import math
cx, cy = x + w // 2, y + h // 2
r = min(w, h) // 2 - 16
# Ziffernblatt-Kreis
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=WHITE, outline=BLACK, width=STROKE)
# Stunden-Markierungen (kleine punkte)
for i in range(12):
angle = (i / 12) * 2 * math.pi - math.pi / 2
mx = cx + int((r - 12) * math.cos(angle))
my = cy + int((r - 12) * math.sin(angle))
draw.ellipse((mx - 3, my - 3, mx + 3, my + 3), fill=BLACK)
# Zeiger
from datetime import datetime
now = datetime.now()
hour_angle = ((now.hour % 12) / 12) * 2 * math.pi - math.pi / 2
min_angle = (now.minute / 60) * 2 * math.pi - math.pi / 2
# Stundenzeiger
hx = cx + int(r * 0.5 * math.cos(hour_angle))
hy = cy + int(r * 0.5 * math.sin(hour_angle))
draw.line((cx, cy, hx, hy), fill=BLACK, width=4)
# Minutenzeiger
mx = cx + int(r * 0.75 * math.cos(min_angle))
my = cy + int(r * 0.75 * math.sin(min_angle))
draw.line((cx, cy, mx, my), fill=BLACK, width=2)
# Mittelpunkt
draw.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), fill=BLACK)
# Digitale Zeit darunter
draw.text((cx - 40, cy + r + 8),
now.strftime("%H:%M"),
font=fonts.get("20", fonts.get("default")), fill=BLACK)
# ============================================================================
# Helper
# ============================================================================
def _px(item):
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
return item.x * CELL_W, item.y * CELL_H, item.w * CELL_W, item.h * CELL_H
+600 -74
View File
@@ -4,40 +4,552 @@
<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>
/* ================================================================
ADMIN UI THEME VARIABLES — switch via data-theme="A|B|C" on <body>
LINEAR DESIGN SYSTEM — epaper-dashboard Admin UI
Based on Linear.app design system
================================================================ */
: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 {
/* 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);
}
: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; margin: 0; padding: 0; }
* { box-sizing: border-box; }
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;
@@ -628,31 +1140,45 @@
<div class="card">
<div class="card-header">
<h2>Display Theme</h2>
<h2>Display Design</h2>
<span class="badge" id="currentDesignBadge"></span>
</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>
<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 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 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 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>
@@ -1068,36 +1594,36 @@
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' },
// ============ 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",
};
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');
async function loadDisplayDesign() {
const r = await fetch('/api/config');
if (!r.ok) return;
const d = await r.json();
document.getElementById('displayThemeSelect').value = d.current;
updateThemePreview(d.current);
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 setDisplayTheme(theme) {
updateThemePreview(theme);
const r = await fetch('/api/display_theme', {
async function setDisplayDesign(design) {
await fetch('/api/display_design', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({theme})
body: JSON.stringify({design}),
});
if (r.ok) triggerRefresh();
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 ============
@@ -1105,7 +1631,7 @@
refreshNetStatus();
refreshScan();
refreshSaved();
loadDisplayTheme();
loadDisplayDesign();
setInterval(refreshNetStatus, 10000);
setInterval(refreshScan, 60000);
setInterval(refreshPreview, 10000);