Files
epaper-dashboard/templates/index.html
T

2155 lines
87 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;
}
/* BUG-05: Save-Status-Indikator (ersetzt den redundanten Speichern-Button) */
.save-indicator {
display: inline-flex; align-items: center; gap: 6px;
margin-left: auto; /* rechtsbündig im toolbar */
padding: 6px 10px;
border-radius: 6px;
font-size: 0.82em;
color: var(--fg-muted);
transition: color 0.15s ease;
}
.save-indicator .save-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
transition: background 0.15s ease, box-shadow 0.15s ease;
}
/* Zustände */
.save-indicator[data-state="idle"] .save-dot { background: var(--ok); }
.save-indicator[data-state="pending"] .save-dot {
background: var(--warn); animation: savePulse 1s ease-in-out infinite;
}
.save-indicator[data-state="saving"] .save-dot {
background: var(--info); animation: savePulse 0.6s ease-in-out infinite;
}
.save-indicator[data-state="error"] .save-dot {
background: var(--alert); box-shadow: 0 0 6px rgba(248, 113, 113, 0.5);
}
.save-indicator[data-state="error"] { color: var(--alert); }
@keyframes savePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.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;
/* BUG-01: Hit-Fläche explizit, damit Pointer-Events sicher ankommen */
pointer-events: auto;
touch-action: none; /* BUG-01: verhindert Browser-Scroll auf Touch */
}
.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;
pointer-events: none; /* BUG-01: ::before ist nur Deko */
}
.grid-item:hover .grid-item-resize::before { opacity: 1; }
.grid-item-resize:hover::before { border-bottom-color: var(--accent); }
/* BUG-01: gedrückte Resize-Handles deutlich machen */
.grid-item-resize:active::before { border-bottom-color: var(--accent-bright); opacity: 1; }
/* BUG-01: gedrückte Items nicht als "grabbing" zeigen — wir nutzen Pointer-Events */
.grid-item:active { cursor: grabbing; }
.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>
<!-- BUG-05: Speichern-Button entfernt (redundant zu debouncedSave).
Stattdessen subtiler Save-Status-Indikator rechts. -->
<div class="save-indicator" id="saveIndicator" data-state="idle">
<span class="save-dot"></span>
<span class="save-label">Gespeichert</span>
</div>
</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 ============
async function saveLayout() {
// BUG-05: Status-Indikator statt redundantem "Speichern"-Button.
// Status-Lebenszyklus:
// pending → saving → idle (success) | error
setSaveState('saving');
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) {
setSaveState('error', 'Fehler: ' + (j.error || '?'));
toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000);
return;
}
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
setSaveState('idle', 'Gespeichert');
// nicht mehr länger toasten — der Indicator reicht
}
// BUG-05: Save-Status-Indikator Helper
function setSaveState(state, label) {
const el = document.getElementById('saveIndicator');
if (!el) return;
el.dataset.state = state;
const lbl = el.querySelector('.save-label');
if (lbl && label) lbl.textContent = label;
// Idle-Reset: nach kurzer Anzeige "Gespeichert" zurück
if (state === 'saving' && !label) {
const lblEl = el.querySelector('.save-label');
if (lblEl) lblEl.textContent = 'Speichern…';
}
if (state === 'idle' && !label) {
const lblEl = el.querySelector('.save-label');
if (lblEl) lblEl.textContent = 'Gespeichert';
}
}
// BUG-05: debouncedSave setzt pending-Status
let saveTimer = null;
function debouncedSave() {
setSaveState('pending', 'Ungespeichert…');
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;
// BUG-01: kein HTML5-draggable mehr — eigene Pointer-Events übernehmen
// Move+Resize in startItemPointer / startResizePointer.
div.setAttribute('touch-action', 'none');
// 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 } — nur für Move verwendet
// ---- BUG-01 + BUG-02: Pointer-Event-basiertes Move & Resize ----
//
// Problem vorher:
// - Items hatten `draggable=true` (HTML5-DnD). Resize-Handle war Kind des
// Items → Browser fired `dragstart` bevor mousedown greifen konnte →
// Resize ging gar nicht oder nur sporadisch.
// - Resize-Drag rief pro mousemove ein komplettes renderGrid() auf → Jank.
//
// Lösung:
// - HTML5-draggable komplett raus. Move + Resize über Pointer-Events.
// - Resize-Handle ist pointer-event-Ziel UND stoppt Propagation → kein
// versehentlicher Move-Start beim Ziehen am Handle.
// - Beide Aktionen nutzen requestAnimationFrame, und nur die Geometrie
// wird via CSS (grid-column/grid-row) aktualisiert — kein renderGrid()
// während Drag. Erst beim Drop / Resize-End kommt der volle re-render
// + save.
function attachDragHandlers(cont, cellEls) {
// Click-to-select (Resize-Handle + Delete bleiben unberührt)
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));
});
// BUG-01: Move-Drag startet auf pointerdown, AUSSER wenn das Target
// der Resize-Handle ist (der hat eigenen Handler und stoppt propagation).
el.addEventListener('pointerdown', startItemPointer);
});
// Resize-Handle: explizit eigene Pointer-Handler, stoppen sofort,
// damit der Item-Handler nicht mitfeuert.
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('pointerdown', startResizePointer);
});
// 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');
});
});
// Keyboard: X to delete selected
document.addEventListener('keydown', onKey);
}
// ---- Move (BUG-01: Pointer-Events, BUG-02: rAF + keine Render-per-Move) ----
function startItemPointer(e) {
// Resize-Handle hat eigenen Handler + stopPropagation — wir landen hier
// also nur, wenn User wirklich auf den Item-Body gedrückt hat.
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
e.preventDefault();
const idx = parseInt(e.currentTarget.dataset.idx);
const it = layoutItems[idx];
const cont = document.getElementById('gridPreview');
// Zellgröße einmal messen (Grid ändert sich nicht während Drag)
const cellGap = 6;
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const cellH = 120;
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('dragging');
dragState = { itemIdx: idx, originItem: { ...it } };
let pendingCol = null, pendingRow = null, rafId = 0;
function pickCellFromEvent(ev) {
const rect = cont.getBoundingClientRect();
const lx = ev.clientX - rect.left - 6; // padding
const ly = ev.clientY - rect.top - 6;
const col = Math.max(0, Math.min(gridCols - 1, Math.floor(lx / (cellW + cellGap))));
const row = Math.max(0, Math.min(gridRows - 1, Math.floor(ly / (cellH + cellGap))));
// clamp so item stays in bounds
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
return [tx, ty];
}
function onMove(ev) {
const [tx, ty] = pickCellFromEvent(ev);
pendingCol = tx; pendingRow = ty;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (pendingCol === null) return;
const tx = pendingCol, ty = pendingRow;
const fits = canPlace(tx, ty, it.w, it.h, idx);
// BUG-02: nur Ghost + Highlight updaten, KEIN renderGrid
const cellIdx = tx + ty * gridCols;
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
const targetCell = cont.querySelector(`[data-cell="${cellIdx}"]`);
if (targetCell) targetCell.classList.toggle('drop-target', fits);
updateGhost(cont, cellEls, tx, ty, it.w, it.h, idx, fits);
});
}
function onUp(ev) {
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
e.currentTarget.removeEventListener('pointercancel', onUp);
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
e.currentTarget.classList.remove('dragging');
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
removeGhost(cont);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
// Bei Drop: finale Position setzen + persistieren
if (pendingCol !== null) {
const tx = pendingCol, ty = pendingRow;
if (canPlace(tx, ty, it.w, it.h, idx)) {
it.x = tx; it.y = ty;
debouncedSave();
}
renderGrid();
}
dragState = null;
}
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
e.currentTarget.addEventListener('pointercancel', onUp);
}
// ---- Resize (BUG-01: eigene Pointer-Events, BUG-02: rAF, kein Render-per-Move) ----
function startResizePointer(e) {
e.preventDefault();
e.stopPropagation(); // BUG-01: Item-Handler NICHT mitfeuern
if (e.button !== undefined && e.button !== 0) return;
const idx = parseInt(e.currentTarget.dataset.resize);
const it = layoutItems[idx];
const cont = document.getElementById('gridPreview');
const cellGap = 6;
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const cellH = 120;
const startX = e.clientX, startY = e.clientY;
const origW = it.w, origH = it.h;
e.currentTarget.setPointerCapture(e.pointerId);
let pendingW = origW, pendingH = origH, rafId = 0;
function applySize() {
// BUG-02: nur CSS-Geometrie des Items anfassen, kein renderGrid
const itemEl = cont.querySelector(`.grid-item[data-idx="${idx}"]`);
if (!itemEl) return;
itemEl.style.gridColumn = `${it.x + 1} / span ${it.w}`;
itemEl.style.gridRow = `${it.y + 1} / span ${it.h}`;
// Meta-Text aktualisieren
const meta = itemEl.querySelector('.grid-item-meta');
if (meta) meta.textContent = `${it.w}×${it.h} · (${it.x},${it.y})`;
}
function onMove(ev) {
const dx = Math.round((ev.clientX - startX) / cellW);
const dy = Math.round((ev.clientY - startY) / cellH);
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
pendingW = newW; pendingH = newH;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (it.w === pendingW && it.h === pendingH) return;
it.w = pendingW; it.h = pendingH;
applySize();
});
}
function onUp(ev) {
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
e.currentTarget.removeEventListener('pointercancel', onUp);
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
// final snap auf letzte berechnete Größe
it.w = pendingW; it.h = pendingH;
debouncedSave();
// Re-render einmal, damit OOB-Markierung und Listener frisch sind
renderGrid();
}
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
e.currentTarget.addEventListener('pointercancel', onUp);
}
// ---- 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 ----
// BUG-01/02: alte startResize()-Funktion entfernt — siehe startResizePointer().
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) {
// BUG-04: 409 = kein Platz mehr, schlage Auto-Pack vor
if (r.status === 409 && j.hint === 'use_auto_pack') {
toast(j.error || 'Kein Platz', 'warn', 6000);
} else {
toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000);
}
return;
}
layoutItems = j.items;
renderGrid();
toast('Widget hinzugefügt', 'success', 2000);
}
// BUG-05: alte saveLayout entfernt — siehe oben (mit setSaveState).
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>