Files
epaper-dashboard/idx.html
T
ki 28124c5617 Initial commit: epaper-dashboard for 7.3" ACeP 7-Color display
- dashboard.py: plugin-based renderer with 4x4 grid layout
- admin.py: web UI with layout editor + plugin configs
- layout.py: pack algorithm, item placement, grid system
- plugins/: clock, weather, system, spotify, strava, gmail, minimax, hello
- network_watchdog.py: WiFi AP/client mode management
- waveshare_epd_init.py: vendor driver stub
2026-08-26 14:12:43 +04:00

1042 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>epaper-dashboard</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230a0a0a'/%3E%3Crect x='10' y='10' width='80' height='80' fill='%23fafafa'/%3E%3Crect x='15' y='15' width='35' height='35' fill='%230080ff'/%3E%3Crect x='50' y='15' width='35' height='35' fill='%23ff0080'/%3E%3Crect x='15' y='50' width='35' height='35' fill='%2300cc00'/%3E%3Crect x='50' y='50' width='35' height='35' fill='%23ff8000'/%3E%3C/svg%3E">
<style>
:root {
--bg: #0a0a0f;
--surface: #15151c;
--surface-2: #1f1f28;
--surface-3: #2a2a35;
--border: #2e2e3a;
--border-light: #3a3a48;
--fg: #f0f0f5;
--fg-muted: #9090a0;
--fg-dim: #6a6a78;
--accent: #6d8eff;
--accent-glow: rgba(109, 142, 255, 0.15);
--ok: #4ade80;
--warn: #fbbf24;
--alert: #f87171;
--info: #60a5fa;
--success: #22c55e;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
margin: 0;
line-height: 1.5;
font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px 32px 80px;
}
/* ============ Header ============ */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-logo {
width: 36px; height: 36px;
background: linear-gradient(135deg, #0080ff 0%, #6d8eff 50%, #f87171 100%);
border-radius: 8px;
display: grid;
place-items: center;
box-shadow: 0 4px 12px var(--accent-glow);
}
.brand-logo svg { color: white; }
.brand-text h1 {
margin: 0; font-size: 1.15em; font-weight: 600; letter-spacing: -0.01em;
}
.brand-text .sub {
color: var(--fg-dim); font-size: 0.78em; font-family: ui-monospace, "SF Mono", monospace;
}
.header-actions {
display: flex; gap: 12px; align-items: center;
}
/* ============ Net-Status Card ============ */
.net-status {
display: flex; align-items: center; gap: 12px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 14px;
font-size: 0.85em;
}
.net-status .mode-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--fg-dim);
box-shadow: 0 0 0 0 var(--fg-dim);
}
.net-status.online .mode-dot {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
}
.net-status.ap .mode-dot {
background: var(--warn);
box-shadow: 0 0 8px var(--warn);
animation: pulse 2s ease-in-out infinite;
}
.net-status.offline .mode-dot { background: var(--alert); }
.net-status.connecting .mode-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.net-status .info-block { display: flex; flex-direction: column; line-height: 1.3; }
.net-status .mode-label { font-weight: 600; }
.net-status .mode-meta { color: var(--fg-muted); font-size: 0.9em; }
.net-status .mode-error { color: var(--alert); font-size: 0.85em; margin-top: 2px; }
/* ============ Buttons ============ */
button, .btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px; border: 1px solid var(--border-light);
background: var(--surface); color: var(--fg);
border-radius: 6px; cursor: pointer; font-size: 0.9em;
font-family: inherit;
transition: all 0.15s ease;
}
button:hover, .btn:hover {
background: var(--surface-2); border-color: var(--accent);
}
button:active, .btn:active { transform: scale(0.98); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary {
background: var(--accent); color: white; border-color: var(--accent);
}
button.primary:hover { background: #5d7eef; border-color: #5d7eef; }
button.danger {
color: var(--alert); border-color: var(--alert);
}
button.danger:hover { background: rgba(248, 113, 113, 0.1); }
button.warn {
background: var(--warn); color: #1a1a1a; border-color: var(--warn);
}
button.ghost {
background: transparent; border-color: transparent;
}
button.ghost:hover { background: var(--surface-2); border-color: var(--border); }
button.icon { padding: 6px 8px; }
button.refresh {
background: var(--accent); color: white; border-color: var(--accent);
box-shadow: 0 2px 8px var(--accent-glow);
}
button.refresh:hover { box-shadow: 0 4px 16px var(--accent-glow); }
/* ============ Tabs ============ */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
.tab {
padding: 10px 16px;
background: transparent; border: none; border-bottom: 2px solid transparent;
color: var(--fg-muted); cursor: pointer;
font-size: 0.92em; font-family: inherit;
transition: all 0.15s ease;
}
.tab:hover { color: var(--fg); }
.tab.active {
color: var(--fg); border-bottom-color: var(--accent);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ============ Cards ============ */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
}
.card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.card-header h2, .card-header h3 {
margin: 0; font-size: 0.95em; font-weight: 600;
display: flex; align-items: center; gap: 8px;
}
.card-header .badge {
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
background: var(--surface-2); color: var(--fg-muted);
font-family: ui-monospace, "SF Mono", monospace;
}
/* ============ Layout Editor ============ */
.layout-toolbar {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
margin-bottom: 16px;
}
.add-form {
display: flex; gap: 8px; align-items: center;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 4px 4px 12px;
}
.add-form select, .add-form input {
border: none; background: transparent; color: var(--fg);
padding: 6px 8px; font-family: inherit; font-size: 0.9em;
}
.add-form select { padding-right: 24px; }
.add-form select:focus, .add-form input:focus { outline: none; }
.add-form button { padding: 6px 12px; font-size: 0.85em; }
.grid-preview {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 120px);
gap: 6px;
background: var(--border);
padding: 6px;
border-radius: 8px;
margin-bottom: 12px;
min-height: 200px;
}
.grid-cell {
background: var(--surface-2);
border-radius: 4px;
transition: background 0.15s;
}
.grid-item {
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%);
border: 1px solid var(--border-light);
border-radius: 6px;
padding: 10px 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
position: relative;
transition: all 0.15s ease;
cursor: grab;
}
.grid-item:hover {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.grid-item.dragging { opacity: 0.5; }
.grid-item.oob {
border-color: var(--alert);
background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%);
}
.grid-item .item-label {
font-weight: 600; font-size: 0.85em; line-height: 1.2;
color: var(--fg);
}
.grid-item .item-meta {
font-size: 0.7em; color: var(--fg-dim);
font-family: ui-monospace, "SF Mono", monospace;
}
.grid-item .item-category {
display: inline-block;
font-size: 0.65em; padding: 1px 6px; border-radius: 3px;
background: var(--accent-glow); color: var(--accent);
text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 4px;
}
.grid-item .item-controls {
position: absolute; top: 6px; right: 6px;
display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s;
}
.grid-item:hover .item-controls { opacity: 1; }
.grid-item .item-controls button {
padding: 2px 6px; font-size: 0.7em; line-height: 1.3;
background: var(--surface); border: 1px solid var(--border);
}
.layout-status {
font-size: 0.85em; color: var(--fg-muted);
display: flex; align-items: center; gap: 6px;
}
.layout-status.has-warnings { color: var(--warn); }
.layout-status::before {
content: ''; width: 6px; height: 6px; border-radius: 50%;
background: currentColor;
}
/* ============ Plugin-Config Cards ============ */
.plugin-config-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 12px;
}
.plugin-config-card h3 {
margin: 0 0 4px; font-size: 0.95em; font-weight: 600;
display: flex; align-items: center; gap: 8px;
}
.plugin-config-card h3 .badge {
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
background: var(--surface-2); color: var(--fg-muted);
font-family: ui-monospace, "SF Mono", monospace;
}
.plugin-config-card .desc {
font-size: 0.82em; color: var(--fg-muted); margin-bottom: 14px;
}
.plugin-config-card label {
display: block; margin: 12px 0 4px;
font-size: 0.78em; color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.04em;
font-weight: 500;
}
.plugin-config-card input[type=text],
.plugin-config-card input[type=number],
.plugin-config-card input[type=password],
.plugin-config-card select {
width: 100%; padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg);
font-size: 0.9em;
font-family: inherit;
transition: all 0.15s;
}
.plugin-config-card input:focus, .plugin-config-card select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.plugin-config-card input[type=checkbox] {
width: 16px; height: 16px;
accent-color: var(--accent);
}
.plugin-config-card .checkbox-row {
display: flex; align-items: center; gap: 8px; margin: 12px 0;
}
.plugin-config-card .checkbox-row label {
margin: 0; text-transform: none; letter-spacing: 0; font-size: 0.88em;
color: var(--fg);
}
.plugin-config-card .help {
font-size: 0.78em; color: var(--fg-dim);
margin-top: 4px; font-style: italic;
}
.plugin-config-card .secret-row {
display: flex; gap: 6px; align-items: stretch;
}
.plugin-config-card .secret-row input {
flex: 1; font-family: ui-monospace, "SF Mono", monospace;
}
.plugin-config-card .secret-row button {
flex: 0 0 auto; padding: 0 10px;
color: var(--alert); border-color: var(--alert);
}
.plugin-config-card .actions {
display: flex; gap: 8px; margin-top: 16px;
padding-top: 12px; border-top: 1px solid var(--border);
}
.plugin-config-card .save-status {
font-size: 0.78em; color: var(--success);
display: none; align-items: center; gap: 4px;
}
.plugin-config-card .save-status.visible { display: inline-flex; }
.plugin-config-card .save-status::before {
content: '✓'; font-weight: bold;
}
/* ============ Preview ============ */
.preview-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 24px;
}
.preview-card .preview-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 12px;
}
.preview-card .preview-header h2 {
margin: 0; font-size: 0.95em; font-weight: 600;
}
.preview-card .meta {
color: var(--fg-muted); font-size: 0.85em;
}
.preview-card img {
max-width: 100%; height: auto; display: block;
border-radius: 6px; border: 1px solid var(--border);
image-rendering: pixelated;
}
/* ============ Network panel ============ */
.network-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
}
.network-grid > .card { margin-bottom: 0; }
.network-grid .full-width { grid-column: 1 / -1; }
.network-grid h3 {
margin: 0 0 12px; font-size: 0.95em; font-weight: 600;
}
.network-grid label {
display: block; margin: 12px 0 4px;
font-size: 0.78em; color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.04em;
}
.network-grid input, .network-grid select {
width: 100%; padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg);
font-family: inherit;
}
.network-grid input:focus, .network-grid select:focus {
outline: none; border-color: var(--accent);
}
.network-grid .checkbox-row {
display: flex; align-items: center; gap: 8px; margin: 12px 0;
}
.network-grid .checkbox-row label { margin: 0; text-transform: none; letter-spacing: 0; }
.network-grid ul#savedList {
list-style: none; padding: 0; margin: 0;
}
.network-grid ul#savedList li {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid var(--border);
}
.network-grid ul#savedList li:last-child { border-bottom: 0; }
.network-grid ul#savedList code {
font-family: ui-monospace, "SF Mono", monospace;
font-size: 0.85em;
}
.network-grid details {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
}
.network-grid details summary {
cursor: pointer; font-weight: 500; color: var(--fg-muted);
}
.network-grid details code {
background: var(--bg); padding: 2px 6px; border-radius: 3px;
font-size: 0.9em;
}
/* ============ Empty state ============ */
.empty-state {
text-align: center; padding: 40px 20px;
color: var(--fg-muted);
}
.empty-state .icon {
font-size: 2em; margin-bottom: 8px; opacity: 0.5;
}
.empty-state .hint { font-size: 0.85em; margin-top: 4px; }
/* ============ Responsive ============ */
@media (max-width: 900px) {
.container { padding: 16px; }
.network-grid { grid-template-columns: 1fr; }
.app-header { flex-direction: column; align-items: flex-start; gap: 16px; }
.header-actions { width: 100%; flex-wrap: wrap; }
}
/* ============ Scrollbar ============ */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
</style>
</head>
<body>
<div class="container">
<header class="app-header">
<div class="brand">
<div class="brand-logo">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="9" y1="21" x2="9" y2="9"/>
</svg>
</div>
<div class="brand-text">
<h1>epaper-dashboard</h1>
<div class="sub">7.3" ACeP · 4×4 grid</div>
</div>
</div>
<div class="header-actions">
<div class="net-status" id="netStatus">
<div class="mode-dot"></div>
<div class="info-block">
<div class="mode-label" id="netMode"></div>
<div class="mode-meta">
<span id="netSsid"></span> · <span id="netIp"></span>
</div>
<div class="mode-error" id="netError"></div>
</div>
</div>
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
Refresh
</button>
</div>
</header>
<div class="tabs">
<button class="tab active" data-tab="layout" onclick="switchTab('layout')">Layout</button>
<button class="tab" data-tab="plugins" onclick="switchTab('plugins')">Plugins</button>
<button class="tab" data-tab="settings" onclick="switchTab('settings')">Settings</button>
<button class="tab" data-tab="network" onclick="switchTab('network')">Network</button>
</div>
<!-- ========== LAYOUT TAB ========== -->
<div class="tab-content active" id="tab-layout">
<div class="card">
<div class="card-header">
<h2>Grid Layout <span class="badge">4×4 · 6 items</span></h2>
<div class="layout-status" id="layoutStatus"></div>
</div>
<div class="layout-toolbar">
<form class="add-form" onsubmit="return addItem(event)">
<select id="newItemPlugin" required>
<option value="gmail">Gmail Unread</option>
<option value="hello">Hello / Demo</option>
<option value="minimax">MiniMax Token Usage</option>
<option value="spotify">Spotify (Last.fm)</option>
<option value="strava">Strava Aktivitäten</option>
<option value="system">System (CPU/RAM)</option>
<option value="clock">Uhrzeit / Datum</option>
<option value="weather">Wetter (Open-Meteo)</option>
</select>
<select id="newItemSize" title="Default-Größe">
<option value="auto">auto</option>
<option value="1x1">1×1</option>
<option value="2x1">2×1</option>
<option value="1x2">1×2</option>
<option value="2x2">2×2</option>
<option value="4x1">4×1</option>
<option value="1x4">1×4</option>
<option value="2x4">2×4</option>
<option value="4x2">4×2</option>
<option value="3x1">3×1</option>
<option value="1x3">1×3</option>
<option value="3x2">3×2</option>
<option value="2x3">2×3</option>
<option value="3x3">3×3</option>
<option value="4x4">4×4</option>
</select>
<button type="submit" class="primary">+ Hinzufügen</button>
</form>
<button onclick="packAll()">Auto-Pack</button>
<button onclick="saveLayout()">Layout speichern</button>
</div>
<div id="gridPreview" class="grid-preview"></div>
<div class="meta" style="text-align: center;">
Position und Größe per Buttons am Item. Auto-Pack ordnet nach Größe (groß zuerst).
</div>
</div>
<div class="preview-card">
<div class="preview-header">
<h2>Live Preview</h2>
<div class="meta" id="previewMeta">Auto-refresh alle 10s</div>
</div>
<img id="previewImg" src="/snapshot.png?t=1787697523" alt="preview">
</div>
</div>
<!-- ========== PLUGINS TAB ========== -->
<div class="tab-content" id="tab-plugins">
<div class="card">
<div class="card-header">
<h2>Plugin-Konfiguration</h2>
<div class="meta" id="pluginCount"></div>
</div>
<div id="pluginConfigs"></div>
</div>
</div>
<!-- ========== SETTINGS TAB ========== -->
<div class="tab-content" id="tab-settings">
<div class="card">
<div class="card-header">
<h2>Refresh-Intervall</h2>
</div>
<form method="post" action="/config" style="display: flex; gap: 12px; align-items: end;">
<div style="flex: 1;">
<label style="display: block; margin-bottom: 6px; font-size: 0.82em; color: var(--fg-muted);">
Display-Refresh alle (Sekunden, min. 30 empfohlen für ACeP-Displays)
</label>
<input type="number" name="refresh_interval_s" min="30" max="3600"
value="300" step="10"
style="width: 100%; padding: 10px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 1em;">
</div>
<button type="submit" class="primary">Speichern</button>
</form>
<div class="meta" style="margin-top: 12px;">
ACeP-Displays vertragen keine schnellen Refreshes. Hersteller empfiehlt ≥180s.
</div>
</div>
<div class="card">
<div class="card-header">
<h2>System</h2>
</div>
<dl style="display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0;">
<dt class="meta">Service:</dt><dd><code style="font-family: ui-monospace, monospace;">epaper-dashboard.service</code></dd>
<dt class="meta">Logs:</dt><dd><code style="font-family: ui-monospace, monospace;">journalctl -u epaper-dashboard -f</code></dd>
<dt class="meta">Config:</dt><dd><code style="font-family: ui-monospace, monospace;">config.json</code></dd>
<dt class="meta">Plugins:</dt><dd><code style="font-family: ui-monospace, monospace;">plugins/</code></dd>
</dl>
</div>
</div>
<!-- ========== NETWORK TAB ========== -->
<div class="tab-content" id="tab-network">
<div class="network-grid">
<div class="card">
<h3>Aktueller Status</h3>
<div id="netCurrent" class="meta">lade…</div>
<div style="display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;">
<button onclick="netReconnect()">Reconnect</button>
<button onclick="netDisconnect()">Disconnect</button>
<button class="warn" onclick="netForceAp()">Force AP</button>
<button class="danger" onclick="netStopAp()" id="netStopApBtn" style="display:none;">AP beenden</button>
</div>
<div class="help" id="netHint" style="margin-top: 12px;"></div>
</div>
<div class="card">
<h3>WLAN verbinden / wechseln</h3>
<label>Verfügbare Netzwerke</label>
<select id="ssidSelect" onchange="document.getElementById('ssidManual').value=this.value">
<option value="">— bitte wählen —</option>
</select>
<div class="checkbox-row">
<input type="checkbox" id="ssidManualToggle" onchange="document.getElementById('ssidManual').disabled=!this.checked">
<label for="ssidManualToggle">SSID manuell eingeben</label>
</div>
<input type="text" id="ssidManual" placeholder="SSID" disabled>
<label>Passwort</label>
<input type="password" id="wifiPwd" placeholder="WPA/WPA2 Passwort">
<label>Sicherheit</label>
<select id="wifiSecurity">
<option value="wpa-psk">WPA/WPA2/WPA3</option>
<option value="wep">WEP</option>
<option value="open">Offen</option>
</select>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button class="primary" onclick="netConnect()">Speichern &amp; Verbinden</button>
</div>
<div class="help" id="netConnResult" style="margin-top: 8px;"></div>
</div>
<div class="card">
<h3>Gespeicherte Netzwerke</h3>
<ul id="savedList"></ul>
</div>
<details class="card">
<summary>Recovery-AP Info</summary>
<p>Wenn das konfigurierte WLAN nicht erreichbar ist (z.B. Router defekt, falsches Passwort, Pi umgezogen), startet das System automatisch einen Recovery-Hotspot.</p>
<ul style="padding-left: 20px; line-height: 1.8;">
<li><strong>SSID:</strong> <code>epaper-recovery</code></li>
<li><strong>Passwort:</strong> <code>recovery1234</code></li>
<li><strong>URL im AP-Modus:</strong> <a href="http://10.42.0.1:8080" style="color: var(--accent);">http://10.42.0.1:8080</a></li>
</ul>
</details>
</div>
</div>
</div>
<script>
// ============ Globale Daten ============
let layoutItems = [{"h": 1, "id": "clock1", "plugin": "clock", "w": 4, "x": 0, "y": 0}, {"h": 1, "id": "sys1", "plugin": "system", "w": 2, "x": 0, "y": 1}, {"h": 1, "id": "wth1", "plugin": "weather", "w": 2, "x": 2, "y": 1}, {"h": 2, "id": "mx1", "plugin": "minimax", "w": 2, "x": 0, "y": 2}, {"h": 1, "id": "hl1", "plugin": "hello", "w": 1, "x": 2, "y": 2}, {"h": 1, "id": "hl2", "plugin": "hello", "w": 1, "x": 3, "y": 2}];
let pluginConfigs = {"minimax": {"subscription_key": "sk-cp-JCNLwew6tEFd85FX5aCpiitlVf3EJ24YlkJdEIN9oD0a4jzVfu4WtFfMzsd1pvAOgjk0cF0ukzh_Miy6quNr3cOf90KbyOgfuLbXOiUCpaNlSvwHxmE0oW8"}};
let widgetsMeta = [{"category": "info", "default_size": [1, 1], "defaults": {"oauth_token_json": ""}, "description": "Anzahl ungelesener Emails im Posteingang.", "label": "Gmail Unread", "name": "gmail", "schema": [{"help": "token.json aus einem einmaligen OAuth-Flow.", "key": "oauth_token_json", "label": "OAuth Token (JSON)", "type": "secret"}]}, {"category": "general", "default_size": [1, 1], "defaults": {"color": "fg", "text": "Hello!"}, "description": "Zeigt einen konfigurierbaren Text. Responsiv.", "label": "Hello / Demo", "name": "hello", "schema": [{"default": "Hello!", "key": "text", "label": "Text", "type": "string"}, {"choices": ["fg", "info", "ok", "warn", "alert", "accent"], "default": "fg", "key": "color", "label": "Farbe", "type": "select"}]}, {"category": "info", "default_size": [2, 2], "defaults": {"api_base": "https://api.minimax.io", "bar_gradient": true, "bar_thresholds": "ok@50,warn@80,alert@95", "show_credits": true, "subscription_key": ""}, "description": "Quota f\u00fcr MiniMax Token-Plan (5-Hour + Weekly). Responsive.", "label": "MiniMax Token Usage", "name": "minimax", "schema": [{"help": "Token-Plan Subscription Key. NICHT der pay-as-you-go API-Key.", "key": "subscription_key", "label": "Subscription Key", "type": "secret"}, {"default": true, "key": "show_credits", "label": "Credit-Balance anzeigen", "type": "bool"}, {"default": "https://api.minimax.io", "key": "api_base", "label": "API Base URL", "type": "string"}, {"default": "ok@50,warn@80,alert@95", "help": "Format: \u0027ok@50,warn@80,alert@95\u0027 oder \u0027ok,warn,alert\u0027 (default 50/80/95)", "key": "bar_thresholds", "label": "Quota-Schwellen (ok,warn,alert)", "type": "string"}, {"default": true, "help": "Wenn aus, einfarbige Bar in der Farbe der aktuellen Schwelle.", "key": "bar_gradient", "label": "Verlaufsmodus", "type": "bool"}]}, {"category": "info", "default_size": [2, 1], "defaults": {"api_key": "", "username": ""}, "description": "Zeigt aktuell gespielten Spotify-Track via Last.fm Scrobble.", "label": "Spotify (Last.fm)", "name": "spotify", "schema": [{"key": "api_key", "label": "Last.fm API Key", "type": "secret"}, {"default": "", "key": "username", "label": "Last.fm Username", "type": "string"}]}, {"category": "fitness", "default_size": [2, 1], "defaults": {"client_id": "", "client_secret": "", "refresh_token": ""}, "description": "Distanz und Rides aus Strava. Setze Client-ID/Secret/Refresh-Token unten.", "label": "Strava Aktivit\u00e4ten", "name": "strava", "schema": [{"key": "client_id", "label": "Strava Client ID", "type": "secret"}, {"key": "client_secret", "label": "Strava Client Secret", "type": "secret"}, {"help": "Einmaliger OAuth-Token. Plugin holt sich access_tokens on-demand.", "key": "refresh_token", "label": "Refresh Token", "type": "secret"}]}, {"category": "system", "default_size": [2, 2], "defaults": {"bar_gradient": true, "bar_thresholds": "ok@50,warn@80,alert@95", "compact": false, "show_load": true, "show_uptime": true}, "description": "Live CPU-Last, RAM-Auslastung, Uptime. Responsive Layout fuer 1x1 bis 4x4.", "label": "System (CPU/RAM)", "name": "system", "schema": [{"default": true, "key": "show_uptime", "label": "Uptime anzeigen", "type": "bool"}, {"default": true, "key": "show_load", "label": "Load Average anzeigen", "type": "bool"}, {"default": false, "key": "compact", "label": "Kompaktmodus (nur Prozent)", "type": "bool"}, {"default": "ok@50,warn@80,alert@95", "help": "Legt fest, ab wann eine Bar gr\u00fcn/gelb/rot wird. Mit \u0027@P\u0027 setzt du die Schwelle in Prozent.", "key": "bar_thresholds", "label": "CPU/RAM-Schwellen (Format: ok,warn,alert oder ok@50,warn@80,alert@95)", "type": "string"}, {"default": true, "help": "Wenn aus, ist die Bar einfarbig in der Farbe der aktuellen Schwelle.", "key": "bar_gradient", "label": "Verlaufsmodus (Balken zeigt alle Stufen gleichzeitig)", "type": "bool"}]}, {"category": "info", "default_size": [2, 2], "defaults": {"accent_color": "fg", "format_24h": true, "show_date": true, "show_seconds": false, "show_weekday": true}, "description": "Aktuelle Uhrzeit und Datum. Responsives Layout fuer 1x1 bis 4x4.", "label": "Uhrzeit / Datum", "name": "clock", "schema": [{"default": true, "key": "format_24h", "label": "24-Stunden-Format", "type": "bool"}, {"default": false, "key": "show_seconds", "label": "Sekunden anzeigen", "type": "bool"}, {"default": true, "key": "show_date", "label": "Datum anzeigen", "type": "bool"}, {"default": true, "key": "show_weekday", "label": "Wochentag anzeigen", "type": "bool"}, {"choices": ["fg", "info", "accent", "ok", "warn", "alert"], "default": "fg", "key": "accent_color", "label": "Akzentfarbe", "type": "select"}]}, {"category": "weather", "default_size": [2, 2], "defaults": {"location": "52.52,13.41", "show_forecast_hours": 4, "show_uv": true, "show_wind_compass": true}, "description": "Aktuelles Wetter, Forecast und Windrose. Kein API-Key. Responsive.", "label": "Wetter (Open-Meteo)", "name": "weather", "schema": [{"default": "52.52,13.41", "help": "Beispiel: 52.52,13.41 f\u00fcr Berlin", "key": "location", "label": "Standort (lat,lon)", "type": "lat_lon"}, {"default": true, "key": "show_uv", "label": "UV-Index anzeigen", "type": "bool"}, {"default": 4, "help": "Wie viele Stunden voraus (1-8)", "key": "show_forecast_hours", "label": "Vorhersage-Stunden", "type": "int"}, {"default": true, "key": "show_wind_compass", "label": "Windrose anzeigen", "type": "bool"}]}];
const gridCols = 4;
const gridRows = 4;
const sizePresets = [[1, 1], [2, 1], [1, 2], [2, 2], [4, 1], [1, 4], [2, 4], [4, 2], [3, 1], [1, 3], [3, 2], [2, 3], [3, 3], [4, 4]];
function widgetMeta(name) {
return widgetsMeta.find(w => w.name === name) || { name, label: name, schema: [], defaults: {} };
}
// ============ Tabs ============
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${name}`));
if (name === 'plugins') renderPluginConfigs();
}
// ============ Layout Editor ============
function renderGrid() {
const cont = document.getElementById('gridPreview');
cont.innerHTML = '';
for (let i = 0; i < gridCols * gridRows; i++) {
const cell = document.createElement('div');
cell.className = 'grid-cell';
cont.appendChild(cell);
}
layoutItems.forEach((it, idx) => {
const div = document.createElement('div');
const m = widgetMeta(it.plugin);
div.className = 'grid-item';
div.dataset.idx = idx;
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows;
if (hasOOB) div.classList.add('oob');
div.innerHTML = `
<div>
<div class="item-label">${m.label}</div>
<span class="item-category">${m.category}</span>
</div>
<div class="item-meta">${it.w}×${it.h} @ (${it.x},${it.y})</div>
<div class="item-controls">
<button class="icon" title="höher" onclick="event.stopPropagation(); moveItem(${idx}, 'up')">▲</button>
<button class="icon" title="tiefer" onclick="event.stopPropagation(); moveItem(${idx}, 'down')">▼</button>
<button class="icon" title="schmaler" onclick="event.stopPropagation(); resizeItem(${idx}, -1, 0)">◀</button>
<button class="icon" title="breiter" onclick="event.stopPropagation(); resizeItem(${idx}, 1, 0)">▶</button>
<button class="icon" title="kürzer" onclick="event.stopPropagation(); resizeItem(${idx}, 0, -1)">▴</button>
<button class="icon" title="länger" onclick="event.stopPropagation(); resizeItem(${idx}, 0, 1)">▾</button>
<button class="icon danger" title="löschen" onclick="event.stopPropagation(); removeItem(${idx})">✕</button>
</div>
`;
cont.appendChild(div);
});
updateLayoutStatus();
}
function updateLayoutStatus() {
const overlaps = findOverlaps(layoutItems);
const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows);
const status = document.getElementById('layoutStatus');
if (overlaps.length) {
status.textContent = `${overlaps.length} Überlappung(en)`;
status.classList.add('has-warnings');
} else if (oob.length) {
status.textContent = `${oob.length} außerhalb`;
status.classList.add('has-warnings');
} else {
status.textContent = `${layoutItems.length} items, OK`;
status.classList.remove('has-warnings');
}
document.getElementById('pluginCount').textContent = `${[...new Set(layoutItems.map(it => it.plugin))].length} Plugins konfiguriert`;
}
function findOverlaps(items) {
const out = [];
for (let i = 0; i < items.length; i++) {
const a = items[i];
const ac = new Set();
for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy));
for (let j = i+1; j < items.length; j++) {
const b = items[j];
for (let dx = 0; dx < b.w; dx++) for (let dy = 0; dy < b.h; dy++) {
if (ac.has((b.x+dx)+','+(b.y+dy))) { out.push([i, j]); break; }
}
}
}
return out;
}
function moveItem(idx, dir) {
const it = layoutItems[idx];
if (dir === 'up') it.y = Math.max(0, it.y - 1);
if (dir === 'down') it.y = Math.min(gridRows - it.h, it.y + 1);
renderGrid();
}
function resizeItem(idx, dw, dh) {
const it = layoutItems[idx];
it.w = Math.max(1, Math.min(gridCols - it.x, it.w + dw));
it.h = Math.max(1, Math.min(gridRows - it.y, it.h + dh));
renderGrid();
}
function removeItem(idx) {
if (!confirm('Item löschen?')) return;
layoutItems.splice(idx, 1);
renderGrid();
}
async function addItem(ev) {
ev.preventDefault();
const plugin = document.getElementById('newItemPlugin').value;
const sizeSel = document.getElementById('newItemSize').value;
const fd = new FormData();
fd.append('plugin', plugin);
if (sizeSel !== 'auto') {
const [w, h] = sizeSel.split('x').map(Number);
fd.append('w', w); fd.append('h', h);
}
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
const j = await r.json();
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
layoutItems = j.items;
renderGrid();
}
async function saveLayout() {
const r = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: layoutItems }),
});
const j = await r.json();
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
}
async function packAll() {
if (!confirm('Alle Items automatisch packen?')) return;
const r = await fetch('/api/layout/pack', { method: 'POST' });
const j = await r.json();
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
}
// ============ Plugin-Configs ============
function renderPluginConfigs() {
const cont = document.getElementById('pluginConfigs');
cont.innerHTML = '';
const usedPlugins = [...new Set(layoutItems.map(it => it.plugin))];
if (usedPlugins.length === 0) {
cont.innerHTML = '<div class="empty-state"><div class="icon">⚙</div>Keine Plugins im Layout. Füge im Layout-Tab Items hinzu.</div>';
return;
}
usedPlugins.forEach(pname => {
const m = widgetMeta(pname);
const cfg = pluginConfigs[pname] || {};
const card = document.createElement('div');
card.className = 'plugin-config-card';
card.dataset.plugin = pname;
let formFields = '';
for (const field of (m.schema || [])) {
const cur = cfg[field.key] !== undefined ? cfg[field.key] : (field.default !== undefined ? field.default : '');
const isSet = cur !== '' && cur !== null && cur !== undefined;
if (field.type === 'bool') {
formFields += `
<div class="checkbox-row">
<input type="checkbox" name="config_${field.key}" ${cur ? 'checked' : ''} id="cfg_${pname}_${field.key}">
<label for="cfg_${pname}_${field.key}">${field.label}</label>
</div>`;
} else if (field.type === 'select') {
let opts = '';
for (const c of (field.choices || [])) {
opts += `<option value="${c}" ${cur === c ? 'selected' : ''}>${c}</option>`;
}
formFields += `<label>${field.label}</label><select name="config_${field.key}">${opts}</select>`;
} else if (field.type === 'secret') {
formFields += `
<label>${field.label}</label>
<div class="secret-row">
<input type="password" autocomplete="new-password" name="config_${field.key}"
placeholder="${isSet ? '•••••••• (gesetzt)' : 'Token / Secret einfügen'}">
${isSet ? `<button type="submit" name="config_${field.key}" value="" formaction="/api/plugin_config/${pname}" formmethod="post" title="Secret entfernen">✕</button>` : ''}
</div>`;
} else if (field.type === 'int' || field.type === 'float') {
formFields += `<label>${field.label}</label>
<input type="number" step="${field.type === 'float' ? '0.01' : '1'}" name="config_${field.key}" value="${cur}">`;
} else {
formFields += `<label>${field.label}</label>
<input type="text" name="config_${field.key}" value="${cur}">`;
}
if (field.help) formFields += `<div class="help">${field.help}</div>`;
}
card.innerHTML = `
<h3>${m.label} <span class="badge">${pname}</span></h3>
<div class="desc">${m.description || ''}</div>
<form data-plugin="${pname}" onsubmit="return savePluginConfig(event, '${pname}')">
${formFields}
<div class="actions">
<button type="submit" class="primary">Config speichern</button>
<span class="save-status" id="save_status_${pname}">Gespeichert</span>
</div>
</form>`;
cont.appendChild(card);
});
}
async function savePluginConfig(ev, pname) {
ev.preventDefault();
const fd = new FormData(ev.target);
const r = await fetch('/api/plugin_config/' + pname, { method: 'POST', body: fd });
const j = await r.json();
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
pluginConfigs[pname] = j.config;
const status = document.getElementById('save_status_' + pname);
if (status) {
status.classList.add('visible');
setTimeout(() => status.classList.remove('visible'), 2000);
}
setTimeout(refreshPreview, 500);
}
// ============ Refresh + Preview ============
function refreshPreview() {
document.getElementById('previewImg').src = '/snapshot.png?t=' + Date.now();
}
async function triggerRefresh() {
const btn = document.getElementById('refreshBtn');
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/></svg> läuft...';
const r = await fetch('/refresh', { method: 'POST' });
const j = await r.json();
if (j.ipc_response === 'OK') {
setTimeout(() => { refreshPreview(); btn.disabled = false; btn.innerHTML = orig; }, 35000);
} else {
btn.disabled = false; btn.innerHTML = orig;
}
}
// ============ Network ============
async function netFetch(path, opts) {
const r = await fetch(path, opts);
if (r.status === 401) { location.reload(); return null; }
return r.json();
}
function fmtDuration(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm';
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
}
function modeBadge(m) {
return {
client: { txt: 'ONLINE', color: 'online' },
ap: { txt: 'AP MODE', color: 'ap' },
connecting: { txt: 'CONNECTING', color: 'connecting' },
offline: { txt: 'OFFLINE', color: 'offline' },
unknown: { txt: 'UNKNOWN', color: '' },
}[m] || { txt: m.toUpperCase(), color: '' };
}
async function refreshNetStatus() {
const s = await netFetch('/api/network/status'); if (!s) return;
const b = modeBadge(s.mode);
const el = document.getElementById('netStatus');
el.className = 'net-status ' + b.color;
document.getElementById('netMode').textContent = b.txt;
document.getElementById('netSsid').textContent = s.ssid ? `"${s.ssid}"` : '';
document.getElementById('netIp').textContent = s.ip ? s.ip : '—';
document.getElementById('netError').textContent = s.error || '';
document.getElementById('netCurrent').innerHTML = `
<div>SSID: <strong>${s.ssid || '—'}</strong></div>
<div>IP: ${s.ip || '—'}</div>
<div>Signal: ${s.signal || 0}%</div>
<div>Ping: ${s.gateway_ping_ms || '—'} ms</div>
<div>Uptime: ${s.since_change_s != null ? fmtDuration(s.since_change_s) : '—'}</div>
`;
document.getElementById('netStopApBtn').style.display = (s.mode === 'ap') ? '' : 'none';
const hints = {
ap: 'Im AP-Modus mit "epaper-recovery" verbinden und WLAN neu konfigurieren.',
offline: 'Kein WLAN erreichbar. Force AP klicken.',
connecting: 'Verbindung wird aufgebaut, max 30s.',
client: 'Normalbetrieb.',
};
document.getElementById('netHint').textContent = hints[s.mode] || '';
}
async function refreshScan() {
const r = await netFetch('/api/network/scan'); if (!r) return;
const sel = document.getElementById('ssidSelect');
sel.innerHTML = '<option value="">— bitte wählen —</option>';
for (const n of r.networks) {
const opt = document.createElement('option');
opt.value = n.ssid;
opt.textContent = `${n.ssid} (${n.signal}%)`;
sel.appendChild(opt);
}
}
async function refreshSaved() {
const r = await netFetch('/api/network/saved'); if (!r) return;
const ul = document.getElementById('savedList'); ul.innerHTML = '';
if (!r.saved.length) { ul.innerHTML = '<li class="meta">Keine Profile.</li>'; return; }
for (const s of r.saved) {
const li = document.createElement('li');
li.innerHTML = `<code>${s.name}</code>
<button class="ghost danger icon" onclick="netForget('${s.name}')" title="löschen">✕</button>`;
ul.appendChild(li);
}
}
async function netConnect() {
const manual = document.getElementById('ssidManual').value.trim();
const sel = document.getElementById('ssidSelect').value;
const ssid = document.getElementById('ssidManual').disabled ? sel : manual;
if (!ssid) { alert('SSID fehlt'); return; }
const fd = new FormData();
fd.append('ssid', ssid);
fd.append('password', document.getElementById('wifiPwd').value);
fd.append('security', document.getElementById('wifiSecurity').value);
const r = await netFetch('/api/network/connect', { method: 'POST', body: fd });
document.getElementById('netConnResult').textContent = r && r.ok ?
`Verbunden mit "${r.ssid}".` : 'Fehler: ' + (r && r.message || '?');
setTimeout(refreshNetStatus, 10000);
refreshSaved();
}
async function netReconnect() { await netFetch('/api/network/reconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
async function netDisconnect() { if (!confirm('Trennen?')) return; await netFetch('/api/network/disconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
async function netForceAp() { if (!confirm('AP starten?')) return; await netFetch('/api/network/ap/start', { method: 'POST' }); setTimeout(refreshNetStatus, 3000); }
async function netStopAp() { await netFetch('/api/network/ap/stop', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
async function netForget(name) { if (!confirm(`"${name}" löschen?`)) return; await netFetch('/api/network/forget', { method: 'POST', body: new URLSearchParams({ name }) }); refreshSaved(); }
// ============ Init ============
renderGrid();
refreshNetStatus();
refreshScan();
refreshSaved();
setInterval(refreshNetStatus, 10000);
setInterval(refreshScan, 60000);
setInterval(refreshPreview, 10000);
</script>
<style>
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
</style>
</body>
</html>