Files
epaper-dashboard/templates/index.html
T
ki a465912b55 BUG-06: Auto-Pack Undo via Snapshot + actionable Toast
Auto-Pack rief vorher layout_mod.pack() auf und schickte einen
simplen success-Toast. Wer aus Versehen klickte, hatte alle
manuellen Positionen verloren — kein Undo, kein Recovery.

Fix:
- packAll() speichert layoutItems als JSON-Snapshot in einer
  local closure (vor dem pack-Call).
- Nach erfolgreichem Pack: toast() bekommt einen 4. Parameter
  action={label, onClick}.
- Neues toast()-Feature: action-Button im Toast, clickbar trotz
  dismiss-on-click. Klick ruft onClick und dismissed den Toast.
- Bei Undo-Klick: POST /api/layout mit den snapshot-Items →
  Server restored → renderGrid() → 'Layout wiederhergestellt'.
- Modal-Text weist auf die 10s Undo-Möglichkeit hin.

Vorteile:
- Versehentlicher Klick auf Auto-Pack ist recoverable.
- Kein sessionStorage-Bloat, kein Multi-Tab-Konflikt
  (Closure-Variable statt global Storage).
- Toast-Pattern ist jetzt generisch — andere Aktionen
  (z.B. 'Snapshot wiederherstellen' aus Sidebar) können
  denselben Mechanismus nutzen.

Beweis: tests/test_autopack_undo.js (8/8 grün)
  1. Snapshot (JSON.stringify(layoutItems)) wird erstellt
  2. Toast mit action={label:'Rückgängig'}
  3. Undo ruft POST /api/layout mit snapshot-Items
  4. Modal-Body erwähnt 'rückgängig'
  5. CSS .toast-action Klasse vorhanden
  6. toast() unterstützt 4-Args (action-Parameter)
  7. Action-Button Click ruft onClick + dismiss
  8. .toast-action hat pointer-events:auto

Live-Test: GET /?demo=1 → 200, 90847 bytes
  toast-action: 6 occurrences (CSS + JS)
  Rückgängig: 1 mention
  JSON.stringify(layoutItems): 1

Closes #8
2026-08-29 17:44:58 +04:00

2016 lines
81 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-bright: #8da9ff;
--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;
-webkit-font-smoothing: antialiased;
}
.container { max-width: 1400px; margin: 0 auto; padding: 24px 32px 80px; }
/* ============ Layout: Sidebar + Main ============ */
body {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
flex: 0 0 220px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 24px 12px;
display: flex;
flex-direction: column;
gap: 4px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-brand {
display: flex; align-items: center; gap: 12px;
padding: 4px 8px 24px 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.sidebar-brand .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);
}
.sidebar-brand .brand-logo svg { color: white; }
.sidebar-brand h1 {
margin: 0; font-size: 1em; font-weight: 600; letter-spacing: -0.01em;
}
.sidebar-brand .sub {
color: var(--fg-dim); font-size: 0.7em;
font-family: ui-monospace, "SF Mono", monospace;
}
.sidebar-section {
font-size: 0.7em; color: var(--fg-dim);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 12px 12px 6px;
font-weight: 500;
}
.sidebar-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
border-radius: 6px;
color: var(--fg-muted);
background: transparent; border: none;
font-size: 0.9em; font-family: inherit;
cursor: pointer; text-align: left; width: 100%;
transition: all 0.15s ease;
position: relative;
}
.sidebar-item:hover { background: var(--surface-2); color: var(--fg); }
.sidebar-item.active {
background: var(--surface-2);
color: var(--fg);
box-shadow: inset 3px 0 0 var(--accent);
}
.sidebar-item .icon {
flex: 0 0 18px; width: 18px; height: 18px;
display: grid; place-items: center;
color: var(--fg-dim);
}
.sidebar-item.active .icon { color: var(--accent); }
.sidebar-item .badge {
margin-left: auto;
background: var(--surface-3);
color: var(--fg-muted);
font-size: 0.75em;
padding: 2px 8px; border-radius: 10px;
font-family: ui-monospace, "SF Mono", monospace;
min-width: 20px; text-align: center;
}
.sidebar-item.active .badge {
background: var(--accent-glow);
color: var(--accent);
}
.sidebar-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.sidebar-status {
padding: 8px 12px;
border-radius: 6px;
background: var(--surface-2);
border: 1px solid var(--border);
font-size: 0.82em;
line-height: 1.5;
}
.sidebar-status .mode-row {
display: flex; align-items: center; gap: 8px;
font-weight: 600;
}
.sidebar-status .mode-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--fg-dim);
}
.sidebar-status.online .mode-dot {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
}
.sidebar-status.ap .mode-dot {
background: var(--warn);
box-shadow: 0 0 8px var(--warn);
}
.sidebar-status.offline .mode-dot { background: var(--alert); }
.sidebar-status.connecting .mode-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; }
.sidebar-status .meta {
color: var(--fg-muted);
font-family: ui-monospace, "SF Mono", monospace;
font-size: 0.9em;
margin-top: 2px;
}
.main {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
}
.topbar {
position: sticky; top: 0; z-index: 100;
background: rgba(15, 15, 25, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 14px 28px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
}
.topbar .greeting {
font-size: 0.95em; color: var(--fg-muted);
}
.topbar .greeting strong {
color: var(--fg); font-weight: 600;
}
.topbar-actions { display: flex; gap: 10px; align-items: center; }
.topbar-clock {
font-family: ui-monospace, "SF Mono", monospace;
font-size: 0.95em;
color: var(--fg);
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
letter-spacing: 0.04em;
}
.topbar-clock .small { color: var(--fg-muted); font-size: 0.85em; margin-left: 6px; }
.content {
flex: 1; padding: 28px;
max-width: 100%;
}
.content .tab-content { animation: fadeIn 0.2s ease; }
@media (max-width: 900px) {
body { flex-direction: column; }
.sidebar {
width: 100%; height: auto;
flex: 0 0 auto;
position: relative;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 12px;
}
.sidebar-brand { display: none; }
.sidebar-section { display: none; }
.sidebar-item { padding: 6px 10px; }
.sidebar-footer { display: none; }
.sidebar { flex-direction: row; flex-wrap: wrap; gap: 4px; padding: 8px; }
.content { padding: 16px; }
}
.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);
}
.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 { 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: var(--accent-bright); border-color: var(--accent-bright); }
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 (Drag & Drop) ============ */
.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({{ grid.cols }}, 1fr);
grid-template-rows: repeat({{ grid.rows }}, 120px);
gap: 6px;
background: var(--border);
padding: 6px;
border-radius: 8px;
margin-bottom: 12px;
min-height: 200px;
}
/* ---- Empty Cell (drop target) ---- */
.grid-cell {
background: var(--surface-2);
border-radius: 4px;
transition: background 0.12s ease, outline 0.12s ease;
position: relative;
overflow: visible;
}
.grid-cell.drop-target {
background: var(--accent-glow);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.grid-cell.drop-invalid {
background: rgba(248, 113, 113, 0.18);
outline: 2px solid var(--alert);
outline-offset: -2px;
}
/* ---- Grid Item: positioned by CSS Grid (grid-column/grid-row spans) ---- */
.grid-item {
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%);
border: 1px solid var(--border-light);
border-radius: 6px;
padding: 8px 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: visible;
position: relative;
cursor: grab;
user-select: none;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
box-sizing: border-box;
}
/* Occupied cells: item sits inside, cell background fades */
.grid-cell.occupied {
background: transparent;
}
.grid-item:hover { border-color: var(--accent-bright); background: var(--surface-2); }
.grid-item:active { cursor: grabbing; }
.grid-item.dragging {
opacity: 0.25;
cursor: grabbing;
}
.grid-item.selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent), 0 4px 16px var(--accent-glow);
z-index: 10;
}
.grid-item.oob {
border-color: var(--alert);
background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%);
}
/* Item spans multiple cells (e.g. 2x2) — visually extend outside origin cell */
.grid-item.span-2x2 { z-index: 5; }
.grid-item.span-4x1 { z-index: 5; }
.grid-item-label {
font-weight: 600; font-size: 0.85em; line-height: 1.2;
pointer-events: none;
}
.grid-item-meta {
font-size: 0.7em; color: var(--fg-dim);
font-family: ui-monospace, "SF Mono", monospace;
pointer-events: none;
}
.grid-item-category {
display: inline-block;
font-size: 0.65em; padding: 1px 6px; border-radius: 3px;
background: var(--accent-glow); color: var(--accent-bright);
text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 4px;
pointer-events: none;
}
/* Delete button on hover */
.grid-item-delete {
position: absolute; top: 6px; right: 6px;
padding: 2px 6px; font-size: 0.75em;
background: var(--surface-2); border: 1px solid var(--border);
opacity: 0; transition: opacity 0.15s;
cursor: pointer; border-radius: 3px;
color: var(--fg-muted);
}
.grid-item:hover .grid-item-delete { opacity: 1; }
.grid-item-delete:hover { background: var(--alert); color: white; border-color: var(--alert); }
/* Resize handle - bottom-right corner */
.grid-item-resize {
position: absolute; bottom: 0; right: 0;
width: 18px; height: 18px;
cursor: nwse-resize;
z-index: 2;
}
.grid-item-resize::before {
content: ''; position: absolute;
bottom: 3px; right: 3px;
width: 0; height: 0;
border-style: solid;
border-width: 0 0 10px 10px;
border-color: transparent transparent var(--fg-dim) transparent;
opacity: 0.5;
transition: opacity 0.15s;
}
.grid-item:hover .grid-item-resize::before { opacity: 1; }
.grid-item-resize:hover::before { border-bottom-color: var(--accent); }
.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;
}
/* Hint bar at bottom of grid */
.layout-hint {
margin-top: 8px;
padding: 8px 12px;
background: var(--surface-2);
border-radius: 6px;
font-size: 0.8em;
color: var(--fg-muted);
display: flex; gap: 18px; flex-wrap: wrap;
}
.layout-hint kbd {
background: var(--surface-3); color: var(--fg);
padding: 1px 6px; border-radius: 3px;
font-family: ui-monospace, monospace;
font-size: 0.95em;
border: 1px solid var(--border-light);
}
/* ============ 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 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 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;
}
/* ============ Recovery Alert ============ */
.recovery-alert {
display: flex; gap: 20px;
background: linear-gradient(135deg, rgba(248, 113, 113, 0.08) 0%, rgba(251, 191, 36, 0.08) 100%);
border: 1px solid var(--alert);
border-radius: 12px; padding: 20px 24px; margin-bottom: 16px;
position: relative; overflow: hidden;
box-shadow: 0 0 0 1px var(--alert), 0 4px 16px rgba(248, 113, 113, 0.15);
animation: recoveryPulse 3s ease-in-out infinite;
}
@keyframes recoveryPulse {
0%, 100% { box-shadow: 0 0 0 1px var(--alert), 0 4px 16px rgba(248, 113, 113, 0.15); }
50% { box-shadow: 0 0 0 1px var(--alert), 0 4px 24px rgba(248, 113, 113, 0.3); }
}
.recovery-alert::before {
content: ''; position: absolute;
top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, var(--alert) 0%, var(--warn) 50%, var(--alert) 100%);
background-size: 200% 100%;
animation: stripe 2s linear infinite;
}
@keyframes stripe {
0% { background-position: 0% 0; }
100% { background-position: 200% 0; }
}
.recovery-icon {
flex: 0 0 auto; width: 56px; height: 56px;
background: rgba(248, 113, 113, 0.15);
border-radius: 12px; display: grid; place-items: center;
color: var(--alert);
}
.recovery-icon svg { animation: wave 2.5s ease-in-out infinite; }
@keyframes wave {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-15deg); }
75% { transform: rotate(15deg); }
}
.recovery-content { flex: 1; }
.recovery-header {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 16px; margin-bottom: 16px;
}
.recovery-title { font-size: 1.15em; font-weight: 600; color: var(--fg); margin-bottom: 4px; }
.recovery-subtitle {
font-size: 0.85em; color: var(--fg-muted);
font-family: ui-monospace, "SF Mono", monospace;
}
.recovery-badge {
flex: 0 0 auto;
padding: 4px 12px; border-radius: 6px;
background: var(--alert); color: white;
font-size: 0.75em; font-weight: 600;
letter-spacing: 0.08em;
}
.recovery-badge.ap { background: var(--warn); color: #1a1a1a; }
.recovery-badge.connecting { background: var(--info); }
.recovery-steps {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 900px) { .recovery-steps { grid-template-columns: 1fr; } }
.step {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: 8px;
}
.step-num {
flex: 0 0 28px; height: 28px;
background: var(--accent); color: white; border-radius: 50%;
display: grid; place-items: center;
font-weight: 600; font-size: 0.9em;
}
.step-body { flex: 1; min-width: 0; }
.step-body strong { display: block; margin-bottom: 4px; font-size: 0.92em; }
.step-hint { font-size: 0.78em; color: var(--fg-muted); margin-bottom: 8px; }
.wifi-credential {
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px;
}
.wifi-row {
display: flex; align-items: center; gap: 10px;
padding: 4px 0;
}
.wifi-row + .wifi-row { border-top: 1px solid var(--border); }
.wifi-label {
flex: 0 0 70px; font-size: 0.72em; color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.05em;
}
.wifi-value {
flex: 1; min-width: 0;
font-family: ui-monospace, "SF Mono", monospace;
font-size: 0.95em; user-select: all; word-break: break-all;
}
.copy-btn { flex: 0 0 auto; padding: 4px 8px; font-size: 0.85em; }
.url-link {
display: block; padding: 4px 0;
color: var(--accent); text-decoration: none;
font-family: ui-monospace, "SF Mono", monospace;
font-size: 0.95em;
}
.url-link:hover { text-decoration: underline; }
.recovery-actions {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
padding-top: 12px; border-top: 1px solid var(--border);
}
.recovery-duration {
margin-left: auto; font-size: 0.82em; color: var(--fg-muted);
font-family: ui-monospace, "SF Mono", monospace;
}
.recovery-error-msg { font-size: 0.82em; color: var(--alert); margin-top: 4px; }
/* ============ 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); }
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
/* ============ Toast-System ============ */
.toast-container {
position: fixed; bottom: 24px; right: 24px; z-index: 10000;
display: flex; flex-direction: column; gap: 8px;
max-width: 380px; pointer-events: none;
}
.toast {
pointer-events: auto;
padding: 12px 16px; border-radius: 8px;
background: var(--surface-2); border: 1px solid var(--border-light);
color: var(--fg); font-size: 0.9em; line-height: 1.4;
display: flex; align-items: center; gap: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: toastIn 0.25s cubic-bezier(0.2, 0.8, 0.4, 1);
cursor: pointer;
}
.toast:hover { background: var(--surface-3); }
.toast.removing { animation: toastOut 0.2s ease-out forwards; }
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--alert); }
.toast.info { border-left: 3px solid var(--info); }
.toast.warn { border-left: 3px solid var(--warn); }
.toast-icon {
flex: 0 0 18px; height: 18px; display: grid; place-items: center;
font-weight: bold; font-size: 14px;
}
.toast.success .toast-icon { color: var(--success); }
.toast.error .toast-icon { color: var(--alert); }
.toast.info .toast-icon { color: var(--info); }
.toast.warn .toast-icon { color: var(--warn); }
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
.toast-action {
flex: 0 0 auto;
padding: 4px 10px;
background: var(--surface-3);
border: 1px solid var(--border-light);
color: var(--fg);
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
pointer-events: auto; /* BUG-06: Klick auf Action-Button muss trotz Toast-Click-Handler gehen */
}
.toast-action:hover { background: var(--accent); color: white; border-color: var(--accent); }
.toast.success .toast-action { border-color: var(--success); color: var(--success); }
.toast.success .toast-action:hover { background: var(--success); color: var(--bg); }
@keyframes toastIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(20px); }
}
/* ============ Modal (Confirm-Dialog) ============ */
.modal-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
min-width: 360px; max-width: 480px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: modalIn 0.2s cubic-bezier(0.2, 0.8, 0.4, 1);
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-icon {
width: 48px; height: 48px;
border-radius: 12px;
display: grid; place-items: center;
margin-bottom: 14px;
}
.modal-icon.danger { background: rgba(248, 113, 113, 0.15); color: var(--alert); }
.modal-icon.warn { background: rgba(251, 191, 36, 0.15); color: var(--warn); }
.modal-icon.info { background: rgba(109, 142, 255, 0.15); color: var(--accent); }
.modal-title {
font-size: 1.15em; font-weight: 600; margin-bottom: 6px;
}
.modal-body {
font-size: 0.9em; color: var(--fg-muted); line-height: 1.5;
margin-bottom: 20px;
}
.modal-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<aside class="sidebar">
<div class="sidebar-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>
<h1>epaper-dashboard</h1>
<div class="sub">7.3" ACeP</div>
</div>
</div>
<div class="sidebar-section">Layout</div>
<button class="sidebar-item active" data-tab="layout" onclick="switchTab('layout')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
Layout
<span class="badge" id="sbLayout"></span>
</button>
<button class="sidebar-item" data-tab="plugins" onclick="switchTab('plugins')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span>
Plugins
<span class="badge" id="sbPlugins"></span>
</button>
<div class="sidebar-section">System</div>
<button class="sidebar-item" data-tab="settings" onclick="switchTab('settings')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
Settings
</button>
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
Network
<span class="badge" id="sbNet"></span>
</button>
<div class="sidebar-footer">
<div class="sidebar-status" id="sidebarStatus">
<div class="mode-row">
<div class="mode-dot"></div>
<span id="sbMode"></span>
</div>
<div class="meta" id="sbMeta"></div>
</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="greeting">
<strong id="topbarTitle">Layout</strong> · <span id="topbarSubtitle">Widget-Positionen auf dem Display</span>
</div>
<div class="topbar-actions">
<div class="topbar-clock">
<span id="topbarTime">--:--:--</span>
<span class="small" id="topbarDate"></span>
</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="content">
<!-- ========== LAYOUT TAB ========== -->
<div class="tab-content active" id="tab-layout">
<div class="recovery-alert" id="recoveryAlert" style="display: none;">
<div class="recovery-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/>
</svg>
</div>
<div class="recovery-content">
<div class="recovery-header">
<div>
<div class="recovery-title" id="recoveryTitle">WLAN-Verbindung verloren</div>
<div class="recovery-subtitle" id="recoverySubtitle">Letzte Verbindung: —</div>
</div>
<span class="recovery-badge" id="recoveryBadge">OFFLINE</span>
</div>
<div class="recovery-steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<strong>Auf deinem Handy oder Laptop:</strong>
<div class="step-hint">Verbinde dich mit diesem WLAN:</div>
<div class="wifi-credential">
<div class="wifi-row">
<span class="wifi-label">SSID</span>
<code class="wifi-value" id="recoveryApSsid">epaper-recovery</code>
<button class="ghost icon copy-btn" onclick="copyText('recoveryApSsid')"></button>
</div>
<div class="wifi-row">
<span class="wifi-label">Passwort</span>
<code class="wifi-value" id="recoveryApPassword">recovery1234</code>
<button class="ghost icon copy-btn" onclick="copyText('recoveryApPassword')"></button>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<strong>Browser öffnen:</strong>
<div class="step-hint">Nach der Verbindung:</div>
<div class="wifi-credential">
<a class="url-link" href="http://10.42.0.1:8080" id="recoveryApUrl" target="_blank">http://10.42.0.1:8080</a>
</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<strong>Im Admin-UI:</strong>
<div class="step-hint">Auf "Network"-Tab, neues WLAN konfigurieren oder "Reconnect".</div>
</div>
</div>
</div>
<div class="recovery-actions">
<button class="warn" id="recoveryStartApBtn" onclick="recoveryToggleAp()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
</svg>
<span id="recoveryApBtnLabel">AP jetzt starten</span>
</button>
<button onclick="netReconnect()">Reconnect versuchen</button>
<span class="recovery-duration" id="recoveryDuration"></span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>Grid Layout <span class="badge">{{ grid.cols }}×{{ grid.rows }} · {{ layout_items|length }} 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>
{% for w in widgets %}
<option value="{{ w.name }}">{{ w.label }}</option>
{% endfor %}
</select>
<select id="newItemSize" title="Default-Größe">
<option value="auto">auto</option>
{% for sp in size_presets %}
<option value="{{ sp[0] }}x{{ sp[1] }}">{{ sp[0] }}×{{ sp[1] }}</option>
{% endfor %}
</select>
<button type="submit" class="primary">+ Hinzufügen</button>
</form>
<button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button>
<button onclick="saveLayout()" class="primary" title="Layout auf Server speichern">Speichern</button>
</div>
<div id="gridPreview" class="grid-preview"></div>
<div class="layout-hint">
<span><kbd>Drag</kbd> Item verschieben</span>
<span><kbd>Ecke ziehen</kbd> Größe ändern</span>
<span><kbd>X</kbd> Item löschen</span>
<span><kbd>Auto-Pack</kbd> alle Items optimal anordnen</span>
</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={{ now }}" 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)
</label>
<input type="number" name="refresh_interval_s" min="30" max="3600"
value="{{ refresh_interval }}" 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 (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>
// ============ Toast-Helper ============
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
// Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken
// Optional: toast(text, type, duration_ms, action={label, onClick})
// — Toast wird mit action-Button gerendert, Click auf den Button ruft onClick
// auf und dismissed den Toast.
function toast(text, type = "info", duration = 3500, action = null) {
const c = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = 'toast ' + type;
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
if (action) {
el.innerHTML = `<div class="toast-icon">${iconChar}</div>
<div class="toast-text"></div>
<button class="toast-action">${action.label}</button>`;
el.querySelector('.toast-action').addEventListener('click', (e) => {
e.stopPropagation();
try { action.onClick(); } finally { remove(); }
});
} else {
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
}
el.querySelector('.toast-text').textContent = text;
let removed = false;
const remove = () => {
if (removed) return;
removed = true;
el.classList.add('removing');
setTimeout(() => el.remove(), 220);
};
el.addEventListener('click', remove);
c.appendChild(el);
if (duration > 0) setTimeout(remove, duration);
return remove;
}
// ============ Modal-Confirm-Helper ============
// Promise-basiert: const ok = await modalConfirm({ title, body, icon, confirmText, cancelText });
function modalConfirm(opts) {
return new Promise((resolve) => {
const icon = opts.icon || 'warn';
const iconChar = { danger: '🗑', warn: '⚠', info: 'ⓘ' }[icon] || '⚠';
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-card" role="dialog" aria-modal="true">
<div class="modal-icon ${icon}">${iconChar}</div>
<div class="modal-title"></div>
<div class="modal-body"></div>
<div class="modal-actions">
<button class="modal-cancel"></button>
<button class="modal-ok danger"></button>
</div>
</div>`;
overlay.querySelector('.modal-title').textContent = opts.title || 'Bestätigen';
overlay.querySelector('.modal-body').textContent = opts.body || '';
overlay.querySelector('.modal-cancel').textContent = opts.cancelText || 'Abbrechen';
overlay.querySelector('.modal-ok').textContent = opts.confirmText || 'Bestätigen';
const close = (val) => {
document.removeEventListener('keydown', onKey);
overlay.remove();
resolve(val);
};
const onKey = (e) => {
if (e.key === 'Escape') close(false);
if (e.key === 'Enter') close(true);
};
overlay.querySelector('.modal-cancel').addEventListener('click', () => close(false));
overlay.querySelector('.modal-ok').addEventListener('click', () => close(true));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
// Focus den OK-Button (default-Action)
setTimeout(() => overlay.querySelector('.modal-ok').focus(), 50);
});
}
// ============ Globale Daten ============
let layoutItems = {{ layout_items|tojson }};
let pluginConfigs = {{ plugin_configs|tojson }};
let widgetsMeta = {{ widgets|tojson }};
const gridCols = {{ grid.cols }};
const gridRows = {{ grid.rows }};
const sizePresets = {{ size_presets|tojson }};
const demoMode = {{ demo_mode|tojson }};
const urlParams = new URLSearchParams(location.search);
const demoRecovery = urlParams.get("demo_recovery");
function widgetMeta(name) {
return widgetsMeta.find(w => w.name === name) || { name, label: name, schema: [], defaults: {} };
}
// ============ Tabs (Sidebar + Inhalt) ============
const TAB_TITLES = {
layout: ['Layout', 'Widget-Positionen auf dem Display'],
plugins: ['Plugins', 'Plugin-Configs und Secrets'],
settings: ['Settings', 'Display-Refresh und System'],
network: ['Network', 'WLAN-Konfiguration'],
};
function switchTab(name) {
document.querySelectorAll('.sidebar-item').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();
// Topbar-Greeting updaten
const t = TAB_TITLES[name];
if (t) {
document.getElementById('topbarTitle').textContent = t[0];
document.getElementById('topbarSubtitle').textContent = t[1];
}
}
// ============ Topbar Clock ============
function updateClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
document.getElementById('topbarTime').textContent = `${hh}:${mm}:${ss}`;
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
document.getElementById('topbarDate').textContent = `${days[now.getDay()]} ${now.getDate()}.${String(now.getMonth()+1).padStart(2,'0')}`;
}
setInterval(updateClock, 1000);
updateClock();
// ============ DRAG & DROP LAYOUT EDITOR ============
let saveTimer = null;
function debouncedSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveLayout, 500);
}
// ---- Which cells are occupied by which item index ----
function occupiedCells() {
const map = {}; // cellIdx -> itemIdx
layoutItems.forEach((it, idx) => {
for (let dx = 0; dx < it.w; dx++) {
for (let dy = 0; dy < it.h; dy++) {
map[(it.x + dx) + ',' + (it.y + dy)] = idx;
}
}
});
return map;
}
function canPlace(x, y, w, h, ignoreIdx) {
if (x < 0 || y < 0 || x + w > gridCols || y + h > gridRows) return false;
const occ = occupiedCells();
for (let dx = 0; dx < w; dx++) {
for (let dy = 0; dy < h; dy++) {
const cellIdx = (x + dx) + ',' + (y + dy);
const occupier = occ[cellIdx];
if (occupier !== undefined && occupier !== ignoreIdx) return false;
}
}
return true;
}
// ---- Render: build cells + items once ----
function renderGrid() {
const cont = document.getElementById('gridPreview');
const cellGap = 6; // px, matches CSS gap
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap; // account padding+gap
const cellH = 120; // matches CSS grid-template-rows
// Build cell list (always recreate to get accurate measurements)
cont.innerHTML = '';
const cellEls = [];
for (let i = 0; i < gridCols * gridRows; i++) {
const cell = document.createElement('div');
cell.className = 'grid-cell';
cell.dataset.cell = i;
cont.appendChild(cell);
cellEls.push(cell);
}
// Place items into their origin cell
const occ = occupiedCells();
layoutItems.forEach((it, idx) => {
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
const div = document.createElement('div');
const m = widgetMeta(it.plugin);
div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx;
div.draggable = true; // legacy HTML5-DnD bleibt für Move; Resize nutzt separaten Handler
// OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
if (hasOOB) div.classList.add('oob');
div.innerHTML = `
<div>
<div class="grid-item-label">${m.label}</div>
<span class="grid-item-category">${m.category}</span>
</div>
<div class="grid-item-meta">${it.w}×${it.h} · (${it.x},${it.y})</div>
<button class="grid-item-delete" data-idx="${idx}" title="löschen (X)">✕</button>
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
`;
// BUG-03: Span-Geometrie per CSS Grid (grid-column/grid-row) statt
// per Cell-DOM-Anker. Item wird direkt in den Grid-Container gehängt,
// nicht in die Origin-Cell. Damit:
// - NxN-Items rendern visuell über NxN Cells
// - Drag-Events auf Nachbar-Cells werden nicht vom Item-DOM
// verschluckt (Item ist nicht mehr Kind der Cell)
// - Resize (applySize in startResizePointer) kann den Span nahtlos
// aktualisieren ohne den DOM-Anker zu wechseln
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
cont.appendChild(div);
// Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
if (originCell) originCell.classList.add('occupied');
});
// Mark occupied cells
Object.keys(occ).forEach(key => {
const [cx, cy] = key.split(',').map(Number);
const cell = cont.querySelector(`[data-cell="${cx + cy * gridCols}"]`);
if (cell) cell.classList.add('occupied');
});
attachDragHandlers(cont, cellEls);
updateLayoutStatus();
}
// ---- Drag state ----
let dragState = null; // { itemIdx, originItem, ghost }
function attachDragHandlers(cont, cellEls) {
// ---- Item: drag start ----
cont.querySelectorAll('.grid-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
selectItem(parseInt(el.dataset.idx));
});
el.addEventListener('dragstart', e => {
const itemIdx = parseInt(el.dataset.idx);
const it = layoutItems[itemIdx];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(itemIdx));
el.classList.add('dragging');
// Transparent 1x1 pixel drag image so browser doesn't show a default ghost
const empty = document.createElement('canvas');
empty.width = empty.height = 1;
e.dataTransfer.setDragImage(empty, 0, 0);
// Snapshot for ghost
dragState = { itemIdx, originItem: { ...it }, ghost: null };
});
});
// ---- Cell: dragover — show ghost preview ----
cellEls.forEach(cell => {
cell.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragState) return;
const { itemIdx, originItem } = dragState;
const it = layoutItems[itemIdx];
const cellIdx = parseInt(cell.dataset.cell);
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
const fits = canPlace(tx, ty, it.w, it.h, itemIdx);
cell.classList.toggle('drop-target', fits);
cell.classList.toggle('drop-invalid', !fits);
// Ghost: show where item will land
updateGhost(cont, cellEls, tx, ty, it.w, it.h, itemIdx, fits);
});
cell.addEventListener('dragleave', e => {
// Only clear if leaving to outside the cell
if (!cell.contains(e.relatedTarget)) {
cell.classList.remove('drop-target', 'drop-invalid');
removeGhost(cont);
}
});
cell.addEventListener('drop', e => {
e.preventDefault();
if (!dragState) return;
const { itemIdx } = dragState;
const it = layoutItems[itemIdx];
const cellIdx = parseInt(cell.dataset.cell);
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
if (canPlace(tx, ty, it.w, it.h, itemIdx)) {
it.x = tx; it.y = ty;
renderGrid(); // full re-render after drop to reflect new layout
debouncedSave();
}
removeGhost(cont);
cell.classList.remove('drop-target', 'drop-invalid');
dragState = null;
});
});
// ---- Item dragend ----
cont.querySelectorAll('.grid-item').forEach(el => {
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
removeGhost(cont);
dragState = null;
// Re-render to restore any mid-drag state changes (e.g. failed drop)
renderGrid();
});
});
// ---- Delete buttons ----
cont.querySelectorAll('.grid-item-delete').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const item = layoutItems[idx];
const m = widgetMeta(item.plugin);
const ok = await modalConfirm({
icon: 'danger',
title: 'Item löschen?',
body: `"${m.label}" wird aus dem Layout entfernt. Diese Aktion kann nicht rückgängig gemacht werden.`,
confirmText: 'Löschen',
});
if (!ok) return;
layoutItems.splice(idx, 1);
renderGrid();
debouncedSave();
toast(`"${m.label}" gelöscht`, 'success');
});
});
// ---- Resize handles ----
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('mousedown', startResize);
});
// ---- Keyboard: X to delete selected ----
document.addEventListener('keydown', onKey);
}
// ---- Ghost preview during drag ----
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
removeGhost(cont);
const targetCell = cont.querySelector(`[data-cell="${x + y * gridCols}"]`);
if (!targetCell) return;
const ghost = document.createElement('div');
ghost.id = 'drag-ghost';
ghost.style.cssText = `
position: absolute;
inset: 0;
border-radius: 6px;
pointer-events: none;
z-index: 20;
opacity: 0.55;
border: 2px dashed ${fits ? 'var(--accent)' : 'var(--alert)'};
background: ${fits ? 'var(--accent-glow)' : 'rgba(248,113,113,0.15)'};
display: flex; align-items: center; justify-content: center;
font-size: 0.8em; color: ${fits ? 'var(--accent)' : 'var(--alert)'};
`;
ghost.textContent = fits ? `${w}×${h}` : 'Überlappung!';
targetCell.appendChild(ghost);
}
function removeGhost(cont) {
const g = cont.querySelector('#drag-ghost');
if (g) g.remove();
}
// ---- Resize ----
function startResize(e) {
e.preventDefault();
e.stopPropagation();
const idx = parseInt(e.currentTarget.dataset.resize);
const it = layoutItems[idx];
const cellGap = 6;
const cont = document.getElementById('gridPreview');
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const startX = e.clientX, startY = e.clientY;
const origW = it.w, origH = it.h;
function onMove(ev) {
const dx = Math.round((ev.clientX - startX) / cellW);
const dy = Math.round((ev.clientY - startY) / 120);
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
if (newW === it.w && newH === it.h) return;
it.w = newW; it.h = newH;
renderGrid();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
debouncedSave();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function onKey(e) {
if (e.key === 'x' || e.key === 'X') {
const sel = document.querySelector('.grid-item.selected');
if (sel) {
const idx = parseInt(sel.dataset.idx);
const item = layoutItems[idx];
const m = widgetMeta(item.plugin);
modalConfirm({
icon: 'danger',
title: 'Item löschen?',
body: `"${m.label}" wird aus dem Layout entfernt.`,
confirmText: 'Löschen',
}).then(ok => {
if (!ok) return;
layoutItems.splice(idx, 1);
renderGrid();
debouncedSave();
toast(`"${m.label}" gelöscht`, 'success');
});
}
}
}
function selectItem(idx) {
document.querySelectorAll('.grid-item').forEach(el => el.classList.remove('selected'));
const el = document.querySelector(`.grid-item[data-idx="${idx}"]`);
if (el) el.classList.add('selected');
}
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 updateLayoutStatus() {
const overlaps = findOverlaps(layoutItems);
const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0);
const status = document.getElementById('layoutStatus');
if (status) {
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');
}
}
const used = new Set(layoutItems.map(it => it.plugin));
const pc = document.getElementById('pluginCount');
if (pc) pc.textContent = `${used.size} Plugins konfiguriert`;
const sl = document.getElementById('sbLayout');
if (sl) sl.textContent = layoutItems.length;
const sp = document.getElementById('sbPlugins');
if (sp) sp.textContent = used.size;
}
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) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
toast('Widget hinzugefügt', 'success', 2000);
}
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) { toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
toast('Layout gespeichert', 'success');
}
async function packAll() {
const ok = await modalConfirm({
icon: 'warn',
title: 'Alle Items neu anordnen?',
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren. Du kannst den Schritt 10 Sekunden lang rückgängig machen.',
confirmText: 'Neu anordnen',
});
if (!ok) return;
// BUG-06: Snapshot der aktuellen Items in sessionStorage für Undo.
// server gibt neue Items zurück; wir können das alte Layout wiederherstellen.
const snapshot = JSON.stringify(layoutItems);
const r = await fetch('/api/layout/pack', { method: 'POST' });
const j = await r.json();
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
debouncedSave();
// BUG-06: Actionable Toast mit Undo-Button
toast('Layout automatisch angeordnet', 'success', 10000, {
label: 'Rückgängig',
onClick: async () => {
try {
const restored = JSON.parse(snapshot);
// restore via normalen Save-Endpunkt
const r2 = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: restored }),
});
const j2 = await r2.json();
if (!j2.ok) { toast('Undo fehlgeschlagen: ' + (j2.error || '?'), 'error', 5000); return; }
layoutItems = j2.items;
renderGrid();
toast('Layout wiederhergestellt', 'info', 3000);
} catch (err) {
toast('Undo Fehler: ' + err.message, 'error', 5000);
}
}
});
}
// ============ 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.</div>';
return;
}
usedPlugins.forEach(pname => {
const m = widgetMeta(pname);
const cfg = pluginConfigs[pname] || {};
const card = document.createElement('div');
card.className = 'plugin-config-card';
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) { toast('Config speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); 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);
toast(`Config "${pname}" gespeichert`, 'success');
}
// ============ 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 t = toast('Display-Refresh gestartet...', 'info', 0); // 0 = manuell
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; t(); toast('Refresh erfolgreich', 'success'); }, 35000);
} else {
btn.disabled = false; btn.innerHTML = orig; t();
toast('Refresh fehlgeschlagen: ' + (j.ipc_response || 'unknown'), 'error', 5000);
}
}
// ============ Recovery Alert ============
async function refreshRecovery() {
let r;
if (demoRecovery) {
const variant = demoRecovery === "1" ? "offline" : demoRecovery;
r = {
show: true, reason: variant, duration_s: 73, threshold_s: 60,
ap_ssid: "epaper-recovery", ap_password: "recovery1234", ap_url: "http://10.42.0.1:8080",
current_mode: variant === "ap" ? "ap" : variant === "connecting" ? "connecting" : "offline",
current_ssid: "IoT",
current_ip: variant === "offline" ? "" : "10.11.3.144",
error: variant === "offline" ? "wpa_supplicant: deauthentication" : "",
};
} else {
r = await fetch('/api/network/recovery');
if (!r.ok) return;
r = await r.json();
}
const el = document.getElementById('recoveryAlert');
if (!r.show) { el.style.display = 'none'; return; }
el.style.display = 'flex';
const badge = document.getElementById('recoveryBadge');
badge.className = 'recovery-badge';
if (r.reason === 'ap_active') { badge.classList.add('ap'); badge.textContent = 'AP ACTIVE'; }
else if (r.reason === 'connecting_timeout') { badge.classList.add('connecting'); badge.textContent = 'VERBINDUNG LÄUFT'; }
else { badge.textContent = 'OFFLINE'; }
const titles = {
ap_active: 'Recovery-Hotspot aktiv',
connecting_timeout: 'WLAN-Verbindung dauert ungewöhnlich lange',
offline: 'WLAN-Verbindung verloren',
};
document.getElementById('recoveryTitle').textContent = titles[r.reason] || 'Netzwerkproblem';
document.getElementById('recoverySubtitle').textContent = `Letztes WLAN: ${r.current_ssid ? '"'+r.current_ssid+'"' : '—'} · Schwelle: ${r.threshold_s}s`;
document.getElementById('recoveryApSsid').textContent = r.ap_ssid;
document.getElementById('recoveryApPassword').textContent = r.ap_password;
document.getElementById('recoveryApUrl').textContent = r.ap_url;
document.getElementById('recoveryApUrl').href = r.ap_url;
const dur = r.duration_s || 0;
const durStr = dur < 60 ? dur+'s' : dur < 3600 ? Math.floor(dur/60)+'m '+(dur%60)+'s' : Math.floor(dur/3600)+'h '+Math.floor((dur%3600)/60)+'m';
document.getElementById('recoveryDuration').textContent = `seit ${durStr}`;
document.getElementById('recoveryApBtnLabel').textContent = r.current_mode === 'ap' ? 'AP beenden' : 'AP jetzt starten';
window.recoveryToggleAp = () => r.current_mode === 'ap' ? netStopAp() : netForceAp();
}
function copyText(elemId) {
const txt = document.getElementById(elemId).textContent;
navigator.clipboard.writeText(txt).then(() => {
const btn = document.querySelector(`button[onclick*="copyText('${elemId}')"]`);
if (btn) { const orig = btn.innerHTML; btn.innerHTML = '✓'; setTimeout(() => { btn.innerHTML = orig; }, 1500); }
});
}
// ============ 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);
// Sidebar-Status
const sb = document.getElementById('sidebarStatus');
if (sb) {
sb.className = 'sidebar-status ' + b.color;
document.getElementById('sbMode').textContent = b.txt;
document.getElementById('sbMeta').innerHTML =
(s.ssid ? `${s.ssid}<br>` : '') + (s.ip || '—');
}
// Sidebar-Badges (Badge counts items in sub-sections)
const nb = document.getElementById('sbNet');
if (nb) {
nb.textContent = s.mode === 'client' ? '' : (s.mode === 'ap' ? 'AP' : s.mode === 'offline' ? '!' : '');
nb.style.display = s.mode === 'client' ? 'none' : '';
}
// Old net-status card (in case it's still in DOM)
const el = document.getElementById('netStatus');
if (el) {
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>`;
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}')">✕</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) { toast('Bitte SSID wählen oder eingeben', 'warn'); return; }
const fd = new FormData();
fd.append('ssid', ssid);
fd.append('password', document.getElementById('wifiPwd').value);
fd.append('security', document.getElementById('wifiSecurity').value);
document.getElementById('netConnResult').textContent = 'Verbindung läuft...';
const r = await netFetch('/api/network/connect', { method: 'POST', body: fd });
if (r && r.ok) {
document.getElementById('netConnResult').textContent = `Verbunden mit "${r.ssid}".`;
toast(`Mit "${r.ssid}" verbunden`, 'success', 5000);
} else {
const msg = (r && (r.message || r.error)) || 'Unbekannter Fehler';
document.getElementById('netConnResult').textContent = 'Fehler: ' + msg;
toast('WLAN-Verbindung fehlgeschlagen: ' + msg, 'error', 6000);
}
setTimeout(refreshNetStatus, 10000);
refreshSaved();
}
async function netReconnect() { await netFetch('/api/network/reconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); toast('Reconnect angefordert', 'info', 2000); }
async function netDisconnect() {
const ok = await modalConfirm({
icon: 'warn',
title: 'WLAN trennen?',
body: 'Die WLAN-Verbindung wird getrennt. Der Recovery-AP startet automatisch nach ein paar Sekunden, falls keine Verbindung wiederhergestellt werden kann.',
confirmText: 'Trennen',
});
if (!ok) return;
await netFetch('/api/network/disconnect', { method: 'POST' });
toast('WLAN getrennt', 'warn', 3000);
setTimeout(refreshNetStatus, 5000);
}
async function netForceAp() {
const ok = await modalConfirm({
icon: 'warn',
title: 'Recovery-AP jetzt starten?',
body: 'Der WLAN-Client-Modus wird beendet und der Hotspot "epaper-recovery" wird gestartet. Du verlierst die Verbindung zum Admin-UI und musst dich mit dem Recovery-WLAN verbinden, um es wieder zu sehen.',
confirmText: 'AP starten',
});
if (!ok) return;
await netFetch('/api/network/ap/start', { method: 'POST' });
toast('Hotspot wird gestartet...', 'warn', 4000);
setTimeout(refreshNetStatus, 3000);
}
async function netStopAp() {
await netFetch('/api/network/ap/stop', { method: 'POST' });
toast('AP beendet', 'info', 3000);
setTimeout(refreshNetStatus, 5000);
}
async function netForget(name) {
const ok = await modalConfirm({
icon: 'danger',
title: 'WLAN-Profil löschen?',
body: `"${name}" wird aus den gespeicherten Netzwerken entfernt. Du musst das Passwort neu eingeben, um dich wieder zu verbinden.`,
confirmText: 'Löschen',
});
if (!ok) return;
const r = await netFetch('/api/network/forget', { method: 'POST', body: new URLSearchParams({ name }) });
if (r && r.ok) toast(`"${name}" gelöscht`, 'success');
else toast('Löschen fehlgeschlagen', 'error');
refreshSaved();
}
// ============ Init ============
if (!demoMode) {
renderGrid();
refreshNetStatus();
refreshScan();
refreshSaved();
setInterval(refreshNetStatus, 10000);
setInterval(refreshScan, 60000);
setInterval(refreshPreview, 10000);
refreshRecovery();
setInterval(refreshRecovery, 5000);
} else {
renderGrid();
if (demoRecovery) refreshRecovery();
// Demo-Mode: KEINE setInterval-Calls, damit Browser-Tools (z.B. headless
// Chromium) nicht in "Page load timed out" laufen.
}
</script>
</div><!-- /.content -->
</main><!-- /.main -->
</body>
</html>