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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+598
-72
@@ -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 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 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);
|
||||
|
||||
Reference in New Issue
Block a user