- dashboard.py: plugin-based renderer with 4x4 grid layout - admin.py: web UI with layout editor + plugin configs - layout.py: pack algorithm, item placement, grid system - plugins/: clock, weather, system, spotify, strava, gmail, minimax, hello - network_watchdog.py: WiFi AP/client mode management - waveshare_epd_init.py: vendor driver stub
1003 lines
39 KiB
HTML
1003 lines
39 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>epaper-dashboard</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230a0a0a'/%3E%3Crect x='10' y='10' width='80' height='80' fill='%23fafafa'/%3E%3Crect x='15' y='15' width='35' height='35' fill='%230080ff'/%3E%3Crect x='50' y='15' width='35' height='35' fill='%23ff0080'/%3E%3Crect x='15' y='50' width='35' height='35' fill='%2300cc00'/%3E%3Crect x='50' y='50' width='35' height='35' fill='%23ff8000'/%3E%3C/svg%3E">
|
||
<style>
|
||
:root {
|
||
--bg: #0a0a0f;
|
||
--surface: #15151c;
|
||
--surface-2: #1f1f28;
|
||
--surface-3: #2a2a35;
|
||
--border: #2e2e3a;
|
||
--border-light: #3a3a48;
|
||
--fg: #f0f0f5;
|
||
--fg-muted: #9090a0;
|
||
--fg-dim: #6a6a78;
|
||
--accent: #6d8eff;
|
||
--accent-glow: rgba(109, 142, 255, 0.15);
|
||
--ok: #4ade80;
|
||
--warn: #fbbf24;
|
||
--alert: #f87171;
|
||
--info: #60a5fa;
|
||
--success: #22c55e;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
margin: 0;
|
||
line-height: 1.5;
|
||
font-feature-settings: "cv11", "ss01";
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 24px 32px 80px;
|
||
}
|
||
|
||
/* ============ Header ============ */
|
||
.app-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 20px 0;
|
||
margin-bottom: 32px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.brand-logo {
|
||
width: 36px; height: 36px;
|
||
background: linear-gradient(135deg, #0080ff 0%, #6d8eff 50%, #f87171 100%);
|
||
border-radius: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
box-shadow: 0 4px 12px var(--accent-glow);
|
||
}
|
||
.brand-logo svg { color: white; }
|
||
.brand-text h1 {
|
||
margin: 0; font-size: 1.15em; font-weight: 600; letter-spacing: -0.01em;
|
||
}
|
||
.brand-text .sub {
|
||
color: var(--fg-dim); font-size: 0.78em; font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex; gap: 12px; align-items: center;
|
||
}
|
||
|
||
/* ============ Net-Status Card ============ */
|
||
.net-status {
|
||
display: flex; align-items: center; gap: 12px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 8px 14px;
|
||
font-size: 0.85em;
|
||
}
|
||
.net-status .mode-dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--fg-dim);
|
||
box-shadow: 0 0 0 0 var(--fg-dim);
|
||
}
|
||
.net-status.online .mode-dot {
|
||
background: var(--ok);
|
||
box-shadow: 0 0 8px var(--ok);
|
||
}
|
||
.net-status.ap .mode-dot {
|
||
background: var(--warn);
|
||
box-shadow: 0 0 8px var(--warn);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
.net-status.offline .mode-dot { background: var(--alert); }
|
||
.net-status.connecting .mode-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; }
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
.net-status .info-block { display: flex; flex-direction: column; line-height: 1.3; }
|
||
.net-status .mode-label { font-weight: 600; }
|
||
.net-status .mode-meta { color: var(--fg-muted); font-size: 0.9em; }
|
||
.net-status .mode-error { color: var(--alert); font-size: 0.85em; margin-top: 2px; }
|
||
|
||
/* ============ Buttons ============ */
|
||
button, .btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 8px 14px; border: 1px solid var(--border-light);
|
||
background: var(--surface); color: var(--fg);
|
||
border-radius: 6px; cursor: pointer; font-size: 0.9em;
|
||
font-family: inherit;
|
||
transition: all 0.15s ease;
|
||
}
|
||
button:hover, .btn:hover {
|
||
background: var(--surface-2); border-color: var(--accent);
|
||
}
|
||
button:active, .btn:active { transform: scale(0.98); }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
button.primary {
|
||
background: var(--accent); color: white; border-color: var(--accent);
|
||
}
|
||
button.primary:hover { background: #5d7eef; border-color: #5d7eef; }
|
||
button.danger {
|
||
color: var(--alert); border-color: var(--alert);
|
||
}
|
||
button.danger:hover { background: rgba(248, 113, 113, 0.1); }
|
||
button.warn {
|
||
background: var(--warn); color: #1a1a1a; border-color: var(--warn);
|
||
}
|
||
button.ghost {
|
||
background: transparent; border-color: transparent;
|
||
}
|
||
button.ghost:hover { background: var(--surface-2); border-color: var(--border); }
|
||
button.icon { padding: 6px 8px; }
|
||
button.refresh {
|
||
background: var(--accent); color: white; border-color: var(--accent);
|
||
box-shadow: 0 2px 8px var(--accent-glow);
|
||
}
|
||
button.refresh:hover { box-shadow: 0 4px 16px var(--accent-glow); }
|
||
|
||
/* ============ Tabs ============ */
|
||
.tabs {
|
||
display: flex; gap: 4px;
|
||
border-bottom: 1px solid var(--border);
|
||
margin-bottom: 24px;
|
||
}
|
||
.tab {
|
||
padding: 10px 16px;
|
||
background: transparent; border: none; border-bottom: 2px solid transparent;
|
||
color: var(--fg-muted); cursor: pointer;
|
||
font-size: 0.92em; font-family: inherit;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.tab:hover { color: var(--fg); }
|
||
.tab.active {
|
||
color: var(--fg); border-bottom-color: var(--accent);
|
||
}
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
/* ============ Cards ============ */
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.card-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 16px; padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.card-header h2, .card-header h3 {
|
||
margin: 0; font-size: 0.95em; font-weight: 600;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.card-header .badge {
|
||
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--surface-2); color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
|
||
/* ============ Layout Editor ============ */
|
||
.layout-toolbar {
|
||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.add-form {
|
||
display: flex; gap: 8px; align-items: center;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 4px 4px 4px 12px;
|
||
}
|
||
.add-form select, .add-form input {
|
||
border: none; background: transparent; color: var(--fg);
|
||
padding: 6px 8px; font-family: inherit; font-size: 0.9em;
|
||
}
|
||
.add-form select { padding-right: 24px; }
|
||
.add-form select:focus, .add-form input:focus { outline: none; }
|
||
.add-form button { padding: 6px 12px; font-size: 0.85em; }
|
||
|
||
.grid-preview {
|
||
display: grid;
|
||
grid-template-columns: repeat({{ 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;
|
||
}
|
||
.grid-cell {
|
||
background: var(--surface-2);
|
||
border-radius: 4px;
|
||
transition: background 0.15s;
|
||
}
|
||
.grid-item {
|
||
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
overflow: hidden;
|
||
position: relative;
|
||
transition: all 0.15s ease;
|
||
cursor: grab;
|
||
}
|
||
.grid-item:hover {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 1px var(--accent);
|
||
}
|
||
.grid-item.dragging { opacity: 0.5; }
|
||
.grid-item.oob {
|
||
border-color: var(--alert);
|
||
background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%);
|
||
}
|
||
.grid-item .item-label {
|
||
font-weight: 600; font-size: 0.85em; line-height: 1.2;
|
||
color: var(--fg);
|
||
}
|
||
.grid-item .item-meta {
|
||
font-size: 0.7em; color: var(--fg-dim);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.grid-item .item-category {
|
||
display: inline-block;
|
||
font-size: 0.65em; padding: 1px 6px; border-radius: 3px;
|
||
background: var(--accent-glow); color: var(--accent);
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
margin-top: 4px;
|
||
}
|
||
.grid-item .item-controls {
|
||
position: absolute; top: 6px; right: 6px;
|
||
display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s;
|
||
}
|
||
.grid-item:hover .item-controls { opacity: 1; }
|
||
.grid-item .item-controls button {
|
||
padding: 2px 6px; font-size: 0.7em; line-height: 1.3;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
}
|
||
|
||
.layout-status {
|
||
font-size: 0.85em; color: var(--fg-muted);
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.layout-status.has-warnings { color: var(--warn); }
|
||
.layout-status::before {
|
||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
|
||
/* ============ Plugin-Config Cards ============ */
|
||
.plugin-config-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 16px 20px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.plugin-config-card h3 {
|
||
margin: 0 0 4px; font-size: 0.95em; font-weight: 600;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.plugin-config-card h3 .badge {
|
||
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--surface-2); color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.plugin-config-card .desc {
|
||
font-size: 0.82em; color: var(--fg-muted); margin-bottom: 14px;
|
||
}
|
||
.plugin-config-card label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.78em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
font-weight: 500;
|
||
}
|
||
.plugin-config-card input[type=text],
|
||
.plugin-config-card input[type=number],
|
||
.plugin-config-card input[type=password],
|
||
.plugin-config-card select {
|
||
width: 100%; padding: 8px 12px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--fg);
|
||
font-size: 0.9em;
|
||
font-family: inherit;
|
||
transition: all 0.15s;
|
||
}
|
||
.plugin-config-card input:focus, .plugin-config-card select:focus {
|
||
outline: none; border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
}
|
||
.plugin-config-card input[type=checkbox] {
|
||
width: 16px; height: 16px;
|
||
accent-color: var(--accent);
|
||
}
|
||
.plugin-config-card .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.plugin-config-card .checkbox-row label {
|
||
margin: 0; text-transform: none; letter-spacing: 0; font-size: 0.88em;
|
||
color: var(--fg);
|
||
}
|
||
.plugin-config-card .help {
|
||
font-size: 0.78em; color: var(--fg-dim);
|
||
margin-top: 4px; font-style: italic;
|
||
}
|
||
.plugin-config-card .secret-row {
|
||
display: flex; gap: 6px; align-items: stretch;
|
||
}
|
||
.plugin-config-card .secret-row input {
|
||
flex: 1; font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.plugin-config-card .secret-row button {
|
||
flex: 0 0 auto; padding: 0 10px;
|
||
color: var(--alert); border-color: var(--alert);
|
||
}
|
||
.plugin-config-card .actions {
|
||
display: flex; gap: 8px; margin-top: 16px;
|
||
padding-top: 12px; border-top: 1px solid var(--border);
|
||
}
|
||
.plugin-config-card .save-status {
|
||
font-size: 0.78em; color: var(--success);
|
||
display: none; align-items: center; gap: 4px;
|
||
}
|
||
.plugin-config-card .save-status.visible { display: inline-flex; }
|
||
.plugin-config-card .save-status::before {
|
||
content: '✓'; font-weight: bold;
|
||
}
|
||
|
||
/* ============ Preview ============ */
|
||
.preview-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.preview-card .preview-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.preview-card .preview-header h2 {
|
||
margin: 0; font-size: 0.95em; font-weight: 600;
|
||
}
|
||
.preview-card .meta {
|
||
color: var(--fg-muted); font-size: 0.85em;
|
||
}
|
||
.preview-card img {
|
||
max-width: 100%; height: auto; display: block;
|
||
border-radius: 6px; border: 1px solid var(--border);
|
||
image-rendering: pixelated;
|
||
}
|
||
|
||
/* ============ Network panel ============ */
|
||
.network-grid {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||
}
|
||
.network-grid > .card { margin-bottom: 0; }
|
||
.network-grid .full-width { grid-column: 1 / -1; }
|
||
.network-grid h3 {
|
||
margin: 0 0 12px; font-size: 0.95em; font-weight: 600;
|
||
}
|
||
.network-grid label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.78em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
}
|
||
.network-grid input, .network-grid select {
|
||
width: 100%; padding: 8px 12px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--fg);
|
||
font-family: inherit;
|
||
}
|
||
.network-grid input:focus, .network-grid select:focus {
|
||
outline: none; border-color: var(--accent);
|
||
}
|
||
.network-grid .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.network-grid .checkbox-row label { margin: 0; text-transform: none; letter-spacing: 0; }
|
||
.network-grid ul#savedList {
|
||
list-style: none; padding: 0; margin: 0;
|
||
}
|
||
.network-grid ul#savedList li {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.network-grid ul#savedList li:last-child { border-bottom: 0; }
|
||
.network-grid ul#savedList code {
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
font-size: 0.85em;
|
||
}
|
||
.network-grid details {
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 12px 16px;
|
||
}
|
||
.network-grid details summary {
|
||
cursor: pointer; font-weight: 500; color: var(--fg-muted);
|
||
}
|
||
.network-grid details code {
|
||
background: var(--bg); padding: 2px 6px; border-radius: 3px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* ============ Empty state ============ */
|
||
.empty-state {
|
||
text-align: center; padding: 40px 20px;
|
||
color: var(--fg-muted);
|
||
}
|
||
.empty-state .icon {
|
||
font-size: 2em; margin-bottom: 8px; opacity: 0.5;
|
||
}
|
||
.empty-state .hint { font-size: 0.85em; margin-top: 4px; }
|
||
|
||
/* ============ Responsive ============ */
|
||
@media (max-width: 900px) {
|
||
.container { padding: 16px; }
|
||
.network-grid { grid-template-columns: 1fr; }
|
||
.app-header { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||
.header-actions { width: 100%; flex-wrap: wrap; }
|
||
}
|
||
|
||
/* ============ Scrollbar ============ */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: var(--bg); }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<header class="app-header">
|
||
<div class="brand">
|
||
<div class="brand-logo">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<line x1="3" y1="9" x2="21" y2="9"/>
|
||
<line x1="9" y1="21" x2="9" y2="9"/>
|
||
</svg>
|
||
</div>
|
||
<div class="brand-text">
|
||
<h1>epaper-dashboard</h1>
|
||
<div class="sub">7.3" ACeP · 4×4 grid</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<div class="net-status" id="netStatus">
|
||
<div class="mode-dot"></div>
|
||
<div class="info-block">
|
||
<div class="mode-label" id="netMode">—</div>
|
||
<div class="mode-meta">
|
||
<span id="netSsid"></span> · <span id="netIp"></span>
|
||
</div>
|
||
<div class="mode-error" id="netError"></div>
|
||
</div>
|
||
</div>
|
||
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/>
|
||
<path d="M21 3v5h-5"/>
|
||
</svg>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="layout" onclick="switchTab('layout')">Layout</button>
|
||
<button class="tab" data-tab="plugins" onclick="switchTab('plugins')">Plugins</button>
|
||
<button class="tab" data-tab="settings" onclick="switchTab('settings')">Settings</button>
|
||
<button class="tab" data-tab="network" onclick="switchTab('network')">Network</button>
|
||
</div>
|
||
|
||
<!-- ========== LAYOUT TAB ========== -->
|
||
<div class="tab-content active" id="tab-layout">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Grid Layout <span class="badge">{{ 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()">Auto-Pack</button>
|
||
<button onclick="saveLayout()">Layout speichern</button>
|
||
</div>
|
||
|
||
<div id="gridPreview" class="grid-preview"></div>
|
||
<div class="meta" style="text-align: center;">
|
||
Position und Größe per Buttons am Item. Auto-Pack ordnet nach Größe (groß zuerst).
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-card">
|
||
<div class="preview-header">
|
||
<h2>Live Preview</h2>
|
||
<div class="meta" id="previewMeta">Auto-refresh alle 10s</div>
|
||
</div>
|
||
<img id="previewImg" src="/snapshot.png?t={{ 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 für ACeP-Displays)
|
||
</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 & Verbinden</button>
|
||
</div>
|
||
<div class="help" id="netConnResult" style="margin-top: 8px;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Gespeicherte Netzwerke</h3>
|
||
<ul id="savedList"></ul>
|
||
</div>
|
||
|
||
<details class="card">
|
||
<summary>Recovery-AP Info</summary>
|
||
<p>Wenn das konfigurierte WLAN nicht erreichbar ist (z.B. Router defekt, falsches Passwort, Pi umgezogen), startet das System automatisch einen Recovery-Hotspot.</p>
|
||
<ul style="padding-left: 20px; line-height: 1.8;">
|
||
<li><strong>SSID:</strong> <code>epaper-recovery</code></li>
|
||
<li><strong>Passwort:</strong> <code>recovery1234</code></li>
|
||
<li><strong>URL im AP-Modus:</strong> <a href="http://10.42.0.1:8080" style="color: var(--accent);">http://10.42.0.1:8080</a></li>
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ============ Globale Daten ============
|
||
let layoutItems = {{ 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 }};
|
||
|
||
function widgetMeta(name) {
|
||
return widgetsMeta.find(w => w.name === name) || { name, label: name, schema: [], defaults: {} };
|
||
}
|
||
|
||
// ============ Tabs ============
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${name}`));
|
||
if (name === 'plugins') renderPluginConfigs();
|
||
}
|
||
|
||
// ============ Layout Editor ============
|
||
function renderGrid() {
|
||
const cont = document.getElementById('gridPreview');
|
||
cont.innerHTML = '';
|
||
for (let i = 0; i < gridCols * gridRows; i++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'grid-cell';
|
||
cont.appendChild(cell);
|
||
}
|
||
layoutItems.forEach((it, idx) => {
|
||
const div = document.createElement('div');
|
||
const m = widgetMeta(it.plugin);
|
||
div.className = 'grid-item';
|
||
div.dataset.idx = idx;
|
||
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
|
||
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
|
||
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows;
|
||
if (hasOOB) div.classList.add('oob');
|
||
div.innerHTML = `
|
||
<div>
|
||
<div class="item-label">${m.label}</div>
|
||
<span class="item-category">${m.category}</span>
|
||
</div>
|
||
<div class="item-meta">${it.w}×${it.h} @ (${it.x},${it.y})</div>
|
||
<div class="item-controls">
|
||
<button class="icon" title="höher" onclick="event.stopPropagation(); moveItem(${idx}, 'up')">▲</button>
|
||
<button class="icon" title="tiefer" onclick="event.stopPropagation(); moveItem(${idx}, 'down')">▼</button>
|
||
<button class="icon" title="schmaler" onclick="event.stopPropagation(); resizeItem(${idx}, -1, 0)">◀</button>
|
||
<button class="icon" title="breiter" onclick="event.stopPropagation(); resizeItem(${idx}, 1, 0)">▶</button>
|
||
<button class="icon" title="kürzer" onclick="event.stopPropagation(); resizeItem(${idx}, 0, -1)">▴</button>
|
||
<button class="icon" title="länger" onclick="event.stopPropagation(); resizeItem(${idx}, 0, 1)">▾</button>
|
||
<button class="icon danger" title="löschen" onclick="event.stopPropagation(); removeItem(${idx})">✕</button>
|
||
</div>
|
||
`;
|
||
cont.appendChild(div);
|
||
});
|
||
updateLayoutStatus();
|
||
}
|
||
|
||
function updateLayoutStatus() {
|
||
const overlaps = findOverlaps(layoutItems);
|
||
const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows);
|
||
const status = document.getElementById('layoutStatus');
|
||
if (overlaps.length) {
|
||
status.textContent = `${overlaps.length} Überlappung(en)`;
|
||
status.classList.add('has-warnings');
|
||
} else if (oob.length) {
|
||
status.textContent = `${oob.length} außerhalb`;
|
||
status.classList.add('has-warnings');
|
||
} else {
|
||
status.textContent = `${layoutItems.length} items, OK`;
|
||
status.classList.remove('has-warnings');
|
||
}
|
||
document.getElementById('pluginCount').textContent = `${[...new Set(layoutItems.map(it => it.plugin))].length} Plugins konfiguriert`;
|
||
}
|
||
|
||
function findOverlaps(items) {
|
||
const out = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const a = items[i];
|
||
const ac = new Set();
|
||
for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy));
|
||
for (let j = i+1; j < items.length; j++) {
|
||
const b = items[j];
|
||
for (let dx = 0; dx < b.w; dx++) for (let dy = 0; dy < b.h; dy++) {
|
||
if (ac.has((b.x+dx)+','+(b.y+dy))) { out.push([i, j]); break; }
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function moveItem(idx, dir) {
|
||
const it = layoutItems[idx];
|
||
if (dir === 'up') it.y = Math.max(0, it.y - 1);
|
||
if (dir === 'down') it.y = Math.min(gridRows - it.h, it.y + 1);
|
||
renderGrid();
|
||
}
|
||
function resizeItem(idx, dw, dh) {
|
||
const it = layoutItems[idx];
|
||
it.w = Math.max(1, Math.min(gridCols - it.x, it.w + dw));
|
||
it.h = Math.max(1, Math.min(gridRows - it.y, it.h + dh));
|
||
renderGrid();
|
||
}
|
||
function removeItem(idx) {
|
||
if (!confirm('Item löschen?')) return;
|
||
layoutItems.splice(idx, 1);
|
||
renderGrid();
|
||
}
|
||
async function addItem(ev) {
|
||
ev.preventDefault();
|
||
const plugin = document.getElementById('newItemPlugin').value;
|
||
const sizeSel = document.getElementById('newItemSize').value;
|
||
const fd = new FormData();
|
||
fd.append('plugin', plugin);
|
||
if (sizeSel !== 'auto') {
|
||
const [w, h] = sizeSel.split('x').map(Number);
|
||
fd.append('w', w); fd.append('h', h);
|
||
}
|
||
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
}
|
||
async function saveLayout() {
|
||
const r = await fetch('/api/layout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ items: layoutItems }),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
setTimeout(refreshPreview, 500);
|
||
}
|
||
async function packAll() {
|
||
if (!confirm('Alle Items automatisch packen?')) return;
|
||
const r = await fetch('/api/layout/pack', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
setTimeout(refreshPreview, 500);
|
||
}
|
||
|
||
// ============ Plugin-Configs ============
|
||
function renderPluginConfigs() {
|
||
const cont = document.getElementById('pluginConfigs');
|
||
cont.innerHTML = '';
|
||
const usedPlugins = [...new Set(layoutItems.map(it => it.plugin))];
|
||
if (usedPlugins.length === 0) {
|
||
cont.innerHTML = '<div class="empty-state"><div class="icon">⚙</div>Keine Plugins im Layout. Füge im Layout-Tab Items hinzu.</div>';
|
||
return;
|
||
}
|
||
usedPlugins.forEach(pname => {
|
||
const m = widgetMeta(pname);
|
||
const cfg = pluginConfigs[pname] || {};
|
||
const card = document.createElement('div');
|
||
card.className = 'plugin-config-card';
|
||
card.dataset.plugin = pname;
|
||
let formFields = '';
|
||
for (const field of (m.schema || [])) {
|
||
const cur = cfg[field.key] !== undefined ? cfg[field.key] : (field.default !== undefined ? field.default : '');
|
||
const isSet = cur !== '' && cur !== null && cur !== undefined;
|
||
if (field.type === 'bool') {
|
||
formFields += `
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" name="config_${field.key}" ${cur ? 'checked' : ''} id="cfg_${pname}_${field.key}">
|
||
<label for="cfg_${pname}_${field.key}">${field.label}</label>
|
||
</div>`;
|
||
} else if (field.type === 'select') {
|
||
let opts = '';
|
||
for (const c of (field.choices || [])) {
|
||
opts += `<option value="${c}" ${cur === c ? 'selected' : ''}>${c}</option>`;
|
||
}
|
||
formFields += `<label>${field.label}</label><select name="config_${field.key}">${opts}</select>`;
|
||
} else if (field.type === 'secret') {
|
||
formFields += `
|
||
<label>${field.label}</label>
|
||
<div class="secret-row">
|
||
<input type="password" autocomplete="new-password" name="config_${field.key}"
|
||
placeholder="${isSet ? '•••••••• (gesetzt)' : 'Token / Secret einfügen'}">
|
||
${isSet ? `<button type="submit" name="config_${field.key}" value="" formaction="/api/plugin_config/${pname}" formmethod="post" title="Secret entfernen">✕</button>` : ''}
|
||
</div>`;
|
||
} else if (field.type === 'int' || field.type === 'float') {
|
||
formFields += `<label>${field.label}</label>
|
||
<input type="number" step="${field.type === 'float' ? '0.01' : '1'}" name="config_${field.key}" value="${cur}">`;
|
||
} else {
|
||
formFields += `<label>${field.label}</label>
|
||
<input type="text" name="config_${field.key}" value="${cur}">`;
|
||
}
|
||
if (field.help) formFields += `<div class="help">${field.help}</div>`;
|
||
}
|
||
card.innerHTML = `
|
||
<h3>${m.label} <span class="badge">${pname}</span></h3>
|
||
<div class="desc">${m.description || ''}</div>
|
||
<form data-plugin="${pname}" onsubmit="return savePluginConfig(event, '${pname}')">
|
||
${formFields}
|
||
<div class="actions">
|
||
<button type="submit" class="primary">Config speichern</button>
|
||
<span class="save-status" id="save_status_${pname}">Gespeichert</span>
|
||
</div>
|
||
</form>`;
|
||
cont.appendChild(card);
|
||
});
|
||
}
|
||
async function savePluginConfig(ev, pname) {
|
||
ev.preventDefault();
|
||
const fd = new FormData(ev.target);
|
||
const r = await fetch('/api/plugin_config/' + pname, { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Fehler: ' + (j.error || '?')); return; }
|
||
pluginConfigs[pname] = j.config;
|
||
const status = document.getElementById('save_status_' + pname);
|
||
if (status) {
|
||
status.classList.add('visible');
|
||
setTimeout(() => status.classList.remove('visible'), 2000);
|
||
}
|
||
setTimeout(refreshPreview, 500);
|
||
}
|
||
|
||
// ============ Refresh + Preview ============
|
||
function refreshPreview() {
|
||
document.getElementById('previewImg').src = '/snapshot.png?t=' + Date.now();
|
||
}
|
||
async function triggerRefresh() {
|
||
const btn = document.getElementById('refreshBtn');
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true; btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/></svg> läuft...';
|
||
const r = await fetch('/refresh', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.ipc_response === 'OK') {
|
||
setTimeout(() => { refreshPreview(); btn.disabled = false; btn.innerHTML = orig; }, 35000);
|
||
} else {
|
||
btn.disabled = false; btn.innerHTML = orig;
|
||
}
|
||
}
|
||
|
||
// ============ Network ============
|
||
async function netFetch(path, opts) {
|
||
const r = await fetch(path, opts);
|
||
if (r.status === 401) { location.reload(); return null; }
|
||
return r.json();
|
||
}
|
||
function fmtDuration(s) {
|
||
if (s < 60) return s + 's';
|
||
if (s < 3600) return Math.floor(s/60) + 'm';
|
||
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
||
}
|
||
function modeBadge(m) {
|
||
return {
|
||
client: { txt: 'ONLINE', color: 'online' },
|
||
ap: { txt: 'AP MODE', color: 'ap' },
|
||
connecting: { txt: 'CONNECTING', color: 'connecting' },
|
||
offline: { txt: 'OFFLINE', color: 'offline' },
|
||
unknown: { txt: 'UNKNOWN', color: '' },
|
||
}[m] || { txt: m.toUpperCase(), color: '' };
|
||
}
|
||
async function refreshNetStatus() {
|
||
const s = await netFetch('/api/network/status'); if (!s) return;
|
||
const b = modeBadge(s.mode);
|
||
const el = document.getElementById('netStatus');
|
||
el.className = 'net-status ' + b.color;
|
||
document.getElementById('netMode').textContent = b.txt;
|
||
document.getElementById('netSsid').textContent = s.ssid ? `"${s.ssid}"` : '';
|
||
document.getElementById('netIp').textContent = s.ip ? s.ip : '—';
|
||
document.getElementById('netError').textContent = s.error || '';
|
||
document.getElementById('netCurrent').innerHTML = `
|
||
<div>SSID: <strong>${s.ssid || '—'}</strong></div>
|
||
<div>IP: ${s.ip || '—'}</div>
|
||
<div>Signal: ${s.signal || 0}%</div>
|
||
<div>Ping: ${s.gateway_ping_ms || '—'} ms</div>
|
||
<div>Uptime: ${s.since_change_s != null ? fmtDuration(s.since_change_s) : '—'}</div>
|
||
`;
|
||
document.getElementById('netStopApBtn').style.display = (s.mode === 'ap') ? '' : 'none';
|
||
const hints = {
|
||
ap: 'Im AP-Modus mit "epaper-recovery" verbinden und WLAN neu konfigurieren.',
|
||
offline: 'Kein WLAN erreichbar. Force AP klicken.',
|
||
connecting: 'Verbindung wird aufgebaut, max 30s.',
|
||
client: 'Normalbetrieb.',
|
||
};
|
||
document.getElementById('netHint').textContent = hints[s.mode] || '';
|
||
}
|
||
async function refreshScan() {
|
||
const r = await netFetch('/api/network/scan'); if (!r) return;
|
||
const sel = document.getElementById('ssidSelect');
|
||
sel.innerHTML = '<option value="">— bitte wählen —</option>';
|
||
for (const n of r.networks) {
|
||
const opt = document.createElement('option');
|
||
opt.value = n.ssid;
|
||
opt.textContent = `${n.ssid} (${n.signal}%)`;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
async function refreshSaved() {
|
||
const r = await netFetch('/api/network/saved'); if (!r) return;
|
||
const ul = document.getElementById('savedList'); ul.innerHTML = '';
|
||
if (!r.saved.length) { ul.innerHTML = '<li class="meta">Keine Profile.</li>'; return; }
|
||
for (const s of r.saved) {
|
||
const li = document.createElement('li');
|
||
li.innerHTML = `<code>${s.name}</code>
|
||
<button class="ghost danger icon" onclick="netForget('${s.name}')" title="löschen">✕</button>`;
|
||
ul.appendChild(li);
|
||
}
|
||
}
|
||
async function netConnect() {
|
||
const manual = document.getElementById('ssidManual').value.trim();
|
||
const sel = document.getElementById('ssidSelect').value;
|
||
const ssid = document.getElementById('ssidManual').disabled ? sel : manual;
|
||
if (!ssid) { alert('SSID fehlt'); return; }
|
||
const fd = new FormData();
|
||
fd.append('ssid', ssid);
|
||
fd.append('password', document.getElementById('wifiPwd').value);
|
||
fd.append('security', document.getElementById('wifiSecurity').value);
|
||
const r = await netFetch('/api/network/connect', { method: 'POST', body: fd });
|
||
document.getElementById('netConnResult').textContent = r && r.ok ?
|
||
`Verbunden mit "${r.ssid}".` : 'Fehler: ' + (r && r.message || '?');
|
||
setTimeout(refreshNetStatus, 10000);
|
||
refreshSaved();
|
||
}
|
||
async function netReconnect() { await netFetch('/api/network/reconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
|
||
async function netDisconnect() { if (!confirm('Trennen?')) return; await netFetch('/api/network/disconnect', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
|
||
async function netForceAp() { if (!confirm('AP starten?')) return; await netFetch('/api/network/ap/start', { method: 'POST' }); setTimeout(refreshNetStatus, 3000); }
|
||
async function netStopAp() { await netFetch('/api/network/ap/stop', { method: 'POST' }); setTimeout(refreshNetStatus, 5000); }
|
||
async function netForget(name) { if (!confirm(`"${name}" löschen?`)) return; await netFetch('/api/network/forget', { method: 'POST', body: new URLSearchParams({ name }) }); refreshSaved(); }
|
||
|
||
// ============ Init ============
|
||
renderGrid();
|
||
refreshNetStatus();
|
||
refreshScan();
|
||
refreshSaved();
|
||
setInterval(refreshNetStatus, 10000);
|
||
setInterval(refreshScan, 60000);
|
||
setInterval(refreshPreview, 10000);
|
||
</script>
|
||
<style>
|
||
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
||
</style>
|
||
</body>
|
||
</html>
|