1958 lines
78 KiB
HTML
1958 lines
78 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-bright: #8da9ff;
|
||
--accent-glow: rgba(109, 142, 255, 0.15);
|
||
--ok: #4ade80;
|
||
--warn: #fbbf24;
|
||
--alert: #f87171;
|
||
--info: #60a5fa;
|
||
--success: #22c55e;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg); color: var(--fg);
|
||
margin: 0; line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 24px 32px 80px; }
|
||
|
||
/* ============ Layout: Sidebar + Main ============ */
|
||
body {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
}
|
||
.sidebar {
|
||
width: 220px;
|
||
flex: 0 0 220px;
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
padding: 24px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
position: sticky;
|
||
top: 0;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
}
|
||
.sidebar-brand {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 4px 8px 24px 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
margin-bottom: 16px;
|
||
}
|
||
.sidebar-brand .brand-logo {
|
||
width: 36px; height: 36px;
|
||
background: linear-gradient(135deg, #0080ff 0%, #6d8eff 50%, #f87171 100%);
|
||
border-radius: 8px;
|
||
display: grid; place-items: center;
|
||
box-shadow: 0 4px 12px var(--accent-glow);
|
||
}
|
||
.sidebar-brand .brand-logo svg { color: white; }
|
||
.sidebar-brand h1 {
|
||
margin: 0; font-size: 1em; font-weight: 600; letter-spacing: -0.01em;
|
||
}
|
||
.sidebar-brand .sub {
|
||
color: var(--fg-dim); font-size: 0.7em;
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.sidebar-section {
|
||
font-size: 0.7em; color: var(--fg-dim);
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
padding: 12px 12px 6px;
|
||
font-weight: 500;
|
||
}
|
||
.sidebar-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
color: var(--fg-muted);
|
||
background: transparent; border: none;
|
||
font-size: 0.9em; font-family: inherit;
|
||
cursor: pointer; text-align: left; width: 100%;
|
||
transition: all 0.15s ease;
|
||
position: relative;
|
||
}
|
||
.sidebar-item:hover { background: var(--surface-2); color: var(--fg); }
|
||
.sidebar-item.active {
|
||
background: var(--surface-2);
|
||
color: var(--fg);
|
||
box-shadow: inset 3px 0 0 var(--accent);
|
||
}
|
||
.sidebar-item .icon {
|
||
flex: 0 0 18px; width: 18px; height: 18px;
|
||
display: grid; place-items: center;
|
||
color: var(--fg-dim);
|
||
}
|
||
.sidebar-item.active .icon { color: var(--accent); }
|
||
.sidebar-item .badge {
|
||
margin-left: auto;
|
||
background: var(--surface-3);
|
||
color: var(--fg-muted);
|
||
font-size: 0.75em;
|
||
padding: 2px 8px; border-radius: 10px;
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
min-width: 20px; text-align: center;
|
||
}
|
||
.sidebar-item.active .badge {
|
||
background: var(--accent-glow);
|
||
color: var(--accent);
|
||
}
|
||
.sidebar-footer {
|
||
margin-top: auto;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.sidebar-status {
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
font-size: 0.82em;
|
||
line-height: 1.5;
|
||
}
|
||
.sidebar-status .mode-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-weight: 600;
|
||
}
|
||
.sidebar-status .mode-dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--fg-dim);
|
||
}
|
||
.sidebar-status.online .mode-dot {
|
||
background: var(--ok);
|
||
box-shadow: 0 0 8px var(--ok);
|
||
}
|
||
.sidebar-status.ap .mode-dot {
|
||
background: var(--warn);
|
||
box-shadow: 0 0 8px var(--warn);
|
||
}
|
||
.sidebar-status.offline .mode-dot { background: var(--alert); }
|
||
.sidebar-status.connecting .mode-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; }
|
||
.sidebar-status .meta {
|
||
color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
font-size: 0.9em;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.main {
|
||
flex: 1; min-width: 0;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.topbar {
|
||
position: sticky; top: 0; z-index: 100;
|
||
background: rgba(15, 15, 25, 0.85);
|
||
backdrop-filter: blur(12px);
|
||
-webkit-backdrop-filter: blur(12px);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 14px 28px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
gap: 16px;
|
||
}
|
||
.topbar .greeting {
|
||
font-size: 0.95em; color: var(--fg-muted);
|
||
}
|
||
.topbar .greeting strong {
|
||
color: var(--fg); font-weight: 600;
|
||
}
|
||
.topbar-actions { display: flex; gap: 10px; align-items: center; }
|
||
.topbar-clock {
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
font-size: 0.95em;
|
||
color: var(--fg);
|
||
padding: 6px 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.topbar-clock .small { color: var(--fg-muted); font-size: 0.85em; margin-left: 6px; }
|
||
|
||
.content {
|
||
flex: 1; padding: 28px;
|
||
max-width: 100%;
|
||
}
|
||
.content .tab-content { animation: fadeIn 0.2s ease; }
|
||
|
||
@media (max-width: 900px) {
|
||
body { flex-direction: column; }
|
||
.sidebar {
|
||
width: 100%; height: auto;
|
||
flex: 0 0 auto;
|
||
position: relative;
|
||
border-right: none;
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 12px;
|
||
}
|
||
.sidebar-brand { display: none; }
|
||
.sidebar-section { display: none; }
|
||
.sidebar-item { padding: 6px 10px; }
|
||
.sidebar-footer { display: none; }
|
||
.sidebar { flex-direction: row; flex-wrap: wrap; gap: 4px; padding: 8px; }
|
||
.content { padding: 16px; }
|
||
}
|
||
.app-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 20px 0; margin-bottom: 32px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 14px; }
|
||
.brand-logo {
|
||
width: 36px; height: 36px;
|
||
background: linear-gradient(135deg, #0080ff 0%, #6d8eff 50%, #f87171 100%);
|
||
border-radius: 8px;
|
||
display: grid; place-items: center;
|
||
box-shadow: 0 4px 12px var(--accent-glow);
|
||
}
|
||
.brand-logo svg { color: white; }
|
||
.brand-text h1 { margin: 0; font-size: 1.15em; font-weight: 600; letter-spacing: -0.01em; }
|
||
.brand-text .sub {
|
||
color: var(--fg-dim); font-size: 0.78em;
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
|
||
.header-actions { display: flex; gap: 12px; align-items: center; }
|
||
|
||
/* ============ Net-Status Card ============ */
|
||
.net-status {
|
||
display: flex; align-items: center; gap: 12px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 8px 14px;
|
||
font-size: 0.85em;
|
||
}
|
||
.net-status .mode-dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--fg-dim);
|
||
}
|
||
.net-status.online .mode-dot {
|
||
background: var(--ok);
|
||
box-shadow: 0 0 8px var(--ok);
|
||
}
|
||
.net-status.ap .mode-dot {
|
||
background: var(--warn);
|
||
box-shadow: 0 0 8px var(--warn);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
.net-status.offline .mode-dot { background: var(--alert); }
|
||
.net-status.connecting .mode-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; }
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
.net-status .info-block { display: flex; flex-direction: column; line-height: 1.3; }
|
||
.net-status .mode-label { font-weight: 600; }
|
||
.net-status .mode-meta { color: var(--fg-muted); font-size: 0.9em; }
|
||
.net-status .mode-error { color: var(--alert); font-size: 0.85em; margin-top: 2px; }
|
||
|
||
/* ============ Buttons ============ */
|
||
button, .btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 8px 14px; border: 1px solid var(--border-light);
|
||
background: var(--surface); color: var(--fg);
|
||
border-radius: 6px; cursor: pointer; font-size: 0.9em;
|
||
font-family: inherit; transition: all 0.15s ease;
|
||
}
|
||
button:hover, .btn:hover { background: var(--surface-2); border-color: var(--accent); }
|
||
button:active { transform: scale(0.98); }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
button.primary { background: var(--accent); color: white; border-color: var(--accent); }
|
||
button.primary:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||
button.danger { color: var(--alert); border-color: var(--alert); }
|
||
button.danger:hover { background: rgba(248, 113, 113, 0.1); }
|
||
button.warn { background: var(--warn); color: #1a1a1a; border-color: var(--warn); }
|
||
button.ghost { background: transparent; border-color: transparent; }
|
||
button.ghost:hover { background: var(--surface-2); border-color: var(--border); }
|
||
button.icon { padding: 6px 8px; }
|
||
button.refresh {
|
||
background: var(--accent); color: white; border-color: var(--accent);
|
||
box-shadow: 0 2px 8px var(--accent-glow);
|
||
}
|
||
button.refresh:hover { box-shadow: 0 4px 16px var(--accent-glow); }
|
||
|
||
/* ============ Tabs ============ */
|
||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
|
||
.tab {
|
||
padding: 10px 16px; background: transparent;
|
||
border: none; border-bottom: 2px solid transparent;
|
||
color: var(--fg-muted); cursor: pointer;
|
||
font-size: 0.92em; font-family: inherit;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.tab:hover { color: var(--fg); }
|
||
.tab.active { color: var(--fg); border-bottom-color: var(--accent); }
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
/* ============ Cards ============ */
|
||
.card {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 10px; padding: 20px; margin-bottom: 16px;
|
||
}
|
||
.card-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 16px; padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.card-header h2, .card-header h3 {
|
||
margin: 0; font-size: 0.95em; font-weight: 600;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.card-header .badge {
|
||
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--surface-2); color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
|
||
/* ============ LAYOUT EDITOR (Drag & Drop) ============ */
|
||
.layout-toolbar {
|
||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.add-form {
|
||
display: flex; gap: 8px; align-items: center;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 4px 4px 4px 12px;
|
||
}
|
||
.add-form select, .add-form input {
|
||
border: none; background: transparent; color: var(--fg);
|
||
padding: 6px 8px; font-family: inherit; font-size: 0.9em;
|
||
}
|
||
.add-form select { padding-right: 24px; }
|
||
.add-form select:focus, .add-form input:focus { outline: none; }
|
||
.add-form button { padding: 6px 12px; font-size: 0.85em; }
|
||
|
||
.grid-preview {
|
||
display: grid;
|
||
grid-template-columns: repeat({{ grid.cols }}, 1fr);
|
||
grid-template-rows: repeat({{ grid.rows }}, 120px);
|
||
gap: 6px;
|
||
background: var(--border);
|
||
padding: 6px;
|
||
border-radius: 8px;
|
||
margin-bottom: 12px;
|
||
min-height: 200px;
|
||
}
|
||
|
||
/* ---- Empty Cell (drop target) ---- */
|
||
.grid-cell {
|
||
background: var(--surface-2);
|
||
border-radius: 4px;
|
||
transition: background 0.12s ease, outline 0.12s ease;
|
||
position: relative;
|
||
overflow: visible;
|
||
}
|
||
.grid-cell.drop-target {
|
||
background: var(--accent-glow);
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: -2px;
|
||
}
|
||
.grid-cell.drop-invalid {
|
||
background: rgba(248, 113, 113, 0.18);
|
||
outline: 2px solid var(--alert);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
/* ---- Grid Item: positioned by CSS Grid (grid-column/grid-row spans) ---- */
|
||
.grid-item {
|
||
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 6px;
|
||
padding: 8px 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
overflow: visible;
|
||
position: relative;
|
||
cursor: grab;
|
||
user-select: none;
|
||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* Occupied cells: item sits inside, cell background fades */
|
||
.grid-cell.occupied {
|
||
background: transparent;
|
||
}
|
||
.grid-item:hover { border-color: var(--accent-bright); background: var(--surface-2); }
|
||
.grid-item:active { cursor: grabbing; }
|
||
.grid-item.dragging {
|
||
opacity: 0.25;
|
||
cursor: grabbing;
|
||
}
|
||
.grid-item.selected {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px var(--accent), 0 4px 16px var(--accent-glow);
|
||
z-index: 10;
|
||
}
|
||
.grid-item.oob {
|
||
border-color: var(--alert);
|
||
background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%);
|
||
}
|
||
|
||
/* Item spans multiple cells (e.g. 2x2) — visually extend outside origin cell */
|
||
.grid-item.span-2x2 { z-index: 5; }
|
||
.grid-item.span-4x1 { z-index: 5; }
|
||
.grid-item-label {
|
||
font-weight: 600; font-size: 0.85em; line-height: 1.2;
|
||
pointer-events: none;
|
||
}
|
||
.grid-item-meta {
|
||
font-size: 0.7em; color: var(--fg-dim);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
pointer-events: none;
|
||
}
|
||
.grid-item-category {
|
||
display: inline-block;
|
||
font-size: 0.65em; padding: 1px 6px; border-radius: 3px;
|
||
background: var(--accent-glow); color: var(--accent-bright);
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
margin-top: 4px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Delete button on hover */
|
||
.grid-item-delete {
|
||
position: absolute; top: 6px; right: 6px;
|
||
padding: 2px 6px; font-size: 0.75em;
|
||
background: var(--surface-2); border: 1px solid var(--border);
|
||
opacity: 0; transition: opacity 0.15s;
|
||
cursor: pointer; border-radius: 3px;
|
||
color: var(--fg-muted);
|
||
}
|
||
.grid-item:hover .grid-item-delete { opacity: 1; }
|
||
.grid-item-delete:hover { background: var(--alert); color: white; border-color: var(--alert); }
|
||
|
||
/* Resize handle - bottom-right corner */
|
||
.grid-item-resize {
|
||
position: absolute; bottom: 0; right: 0;
|
||
width: 18px; height: 18px;
|
||
cursor: nwse-resize;
|
||
z-index: 2;
|
||
}
|
||
.grid-item-resize::before {
|
||
content: ''; position: absolute;
|
||
bottom: 3px; right: 3px;
|
||
width: 0; height: 0;
|
||
border-style: solid;
|
||
border-width: 0 0 10px 10px;
|
||
border-color: transparent transparent var(--fg-dim) transparent;
|
||
opacity: 0.5;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.grid-item:hover .grid-item-resize::before { opacity: 1; }
|
||
.grid-item-resize:hover::before { border-bottom-color: var(--accent); }
|
||
|
||
.layout-status {
|
||
font-size: 0.85em; color: var(--fg-muted);
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.layout-status.has-warnings { color: var(--warn); }
|
||
.layout-status::before {
|
||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
|
||
/* Hint bar at bottom of grid */
|
||
.layout-hint {
|
||
margin-top: 8px;
|
||
padding: 8px 12px;
|
||
background: var(--surface-2);
|
||
border-radius: 6px;
|
||
font-size: 0.8em;
|
||
color: var(--fg-muted);
|
||
display: flex; gap: 18px; flex-wrap: wrap;
|
||
}
|
||
.layout-hint kbd {
|
||
background: var(--surface-3); color: var(--fg);
|
||
padding: 1px 6px; border-radius: 3px;
|
||
font-family: ui-monospace, monospace;
|
||
font-size: 0.95em;
|
||
border: 1px solid var(--border-light);
|
||
}
|
||
|
||
/* ============ Plugin-Config Cards ============ */
|
||
.plugin-config-card {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 10px; padding: 16px 20px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.plugin-config-card h3 {
|
||
margin: 0 0 4px; font-size: 0.95em; font-weight: 600;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.plugin-config-card h3 .badge {
|
||
font-size: 0.7em; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--surface-2); color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.plugin-config-card .desc { font-size: 0.82em; color: var(--fg-muted); margin-bottom: 14px; }
|
||
.plugin-config-card label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.78em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
font-weight: 500;
|
||
}
|
||
.plugin-config-card input[type=text],
|
||
.plugin-config-card input[type=number],
|
||
.plugin-config-card input[type=password],
|
||
.plugin-config-card select {
|
||
width: 100%; padding: 8px 12px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px; color: var(--fg);
|
||
font-size: 0.9em; font-family: inherit;
|
||
transition: all 0.15s;
|
||
}
|
||
.plugin-config-card input:focus, .plugin-config-card select:focus {
|
||
outline: none; border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
}
|
||
.plugin-config-card input[type=checkbox] {
|
||
width: 16px; height: 16px; accent-color: var(--accent);
|
||
}
|
||
.plugin-config-card .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.plugin-config-card .checkbox-row label {
|
||
margin: 0; text-transform: none; letter-spacing: 0;
|
||
font-size: 0.88em; color: var(--fg);
|
||
}
|
||
.plugin-config-card .help {
|
||
font-size: 0.78em; color: var(--fg-dim);
|
||
margin-top: 4px; font-style: italic;
|
||
}
|
||
.plugin-config-card .secret-row {
|
||
display: flex; gap: 6px; align-items: stretch;
|
||
}
|
||
.plugin-config-card .secret-row input {
|
||
flex: 1; font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.plugin-config-card .secret-row button {
|
||
flex: 0 0 auto; padding: 0 10px;
|
||
color: var(--alert); border-color: var(--alert);
|
||
}
|
||
.plugin-config-card .actions {
|
||
display: flex; gap: 8px; margin-top: 16px;
|
||
padding-top: 12px; border-top: 1px solid var(--border);
|
||
}
|
||
.plugin-config-card .save-status {
|
||
font-size: 0.78em; color: var(--success);
|
||
display: none; align-items: center; gap: 4px;
|
||
}
|
||
.plugin-config-card .save-status.visible { display: inline-flex; }
|
||
.plugin-config-card .save-status::before {
|
||
content: '✓'; font-weight: bold;
|
||
}
|
||
|
||
/* ============ Preview ============ */
|
||
.preview-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.preview-card .preview-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.preview-card .preview-header h2 {
|
||
margin: 0; font-size: 0.95em; font-weight: 600;
|
||
}
|
||
.preview-card .meta { color: var(--fg-muted); font-size: 0.85em; }
|
||
.preview-card img {
|
||
max-width: 100%; height: auto; display: block;
|
||
border-radius: 6px; border: 1px solid var(--border);
|
||
image-rendering: pixelated;
|
||
}
|
||
|
||
/* ============ Network panel ============ */
|
||
.network-grid {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||
}
|
||
.network-grid > .card { margin-bottom: 0; }
|
||
.network-grid h3 { margin: 0 0 12px; font-size: 0.95em; font-weight: 600; }
|
||
.network-grid label {
|
||
display: block; margin: 12px 0 4px;
|
||
font-size: 0.78em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
}
|
||
.network-grid input, .network-grid select {
|
||
width: 100%; padding: 8px 12px;
|
||
background: var(--surface-2); border: 1px solid var(--border);
|
||
border-radius: 6px; color: var(--fg); font-family: inherit;
|
||
}
|
||
.network-grid input:focus, .network-grid select:focus {
|
||
outline: none; border-color: var(--accent);
|
||
}
|
||
.network-grid .checkbox-row {
|
||
display: flex; align-items: center; gap: 8px; margin: 12px 0;
|
||
}
|
||
.network-grid .checkbox-row label { margin: 0; text-transform: none; letter-spacing: 0; }
|
||
.network-grid ul#savedList { list-style: none; padding: 0; margin: 0; }
|
||
.network-grid ul#savedList li {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.network-grid ul#savedList code {
|
||
font-family: ui-monospace, "SF Mono", monospace; font-size: 0.85em;
|
||
}
|
||
.network-grid details {
|
||
background: var(--surface-2); border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 12px 16px;
|
||
}
|
||
.network-grid details summary { cursor: pointer; font-weight: 500; color: var(--fg-muted); }
|
||
.network-grid details code {
|
||
background: var(--bg); padding: 2px 6px; border-radius: 3px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* ============ Recovery Alert ============ */
|
||
.recovery-alert {
|
||
display: flex; gap: 20px;
|
||
background: linear-gradient(135deg, rgba(248, 113, 113, 0.08) 0%, rgba(251, 191, 36, 0.08) 100%);
|
||
border: 1px solid var(--alert);
|
||
border-radius: 12px; padding: 20px 24px; margin-bottom: 16px;
|
||
position: relative; overflow: hidden;
|
||
box-shadow: 0 0 0 1px var(--alert), 0 4px 16px rgba(248, 113, 113, 0.15);
|
||
animation: recoveryPulse 3s ease-in-out infinite;
|
||
}
|
||
@keyframes recoveryPulse {
|
||
0%, 100% { box-shadow: 0 0 0 1px var(--alert), 0 4px 16px rgba(248, 113, 113, 0.15); }
|
||
50% { box-shadow: 0 0 0 1px var(--alert), 0 4px 24px rgba(248, 113, 113, 0.3); }
|
||
}
|
||
.recovery-alert::before {
|
||
content: ''; position: absolute;
|
||
top: 0; left: 0; right: 0; height: 3px;
|
||
background: linear-gradient(90deg, var(--alert) 0%, var(--warn) 50%, var(--alert) 100%);
|
||
background-size: 200% 100%;
|
||
animation: stripe 2s linear infinite;
|
||
}
|
||
@keyframes stripe {
|
||
0% { background-position: 0% 0; }
|
||
100% { background-position: 200% 0; }
|
||
}
|
||
.recovery-icon {
|
||
flex: 0 0 auto; width: 56px; height: 56px;
|
||
background: rgba(248, 113, 113, 0.15);
|
||
border-radius: 12px; display: grid; place-items: center;
|
||
color: var(--alert);
|
||
}
|
||
.recovery-icon svg { animation: wave 2.5s ease-in-out infinite; }
|
||
@keyframes wave {
|
||
0%, 100% { transform: rotate(0); }
|
||
25% { transform: rotate(-15deg); }
|
||
75% { transform: rotate(15deg); }
|
||
}
|
||
.recovery-content { flex: 1; }
|
||
.recovery-header {
|
||
display: flex; justify-content: space-between; align-items: flex-start;
|
||
gap: 16px; margin-bottom: 16px;
|
||
}
|
||
.recovery-title { font-size: 1.15em; font-weight: 600; color: var(--fg); margin-bottom: 4px; }
|
||
.recovery-subtitle {
|
||
font-size: 0.85em; color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.recovery-badge {
|
||
flex: 0 0 auto;
|
||
padding: 4px 12px; border-radius: 6px;
|
||
background: var(--alert); color: white;
|
||
font-size: 0.75em; font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.recovery-badge.ap { background: var(--warn); color: #1a1a1a; }
|
||
.recovery-badge.connecting { background: var(--info); }
|
||
.recovery-steps {
|
||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
@media (max-width: 900px) { .recovery-steps { grid-template-columns: 1fr; } }
|
||
.step {
|
||
display: flex; gap: 10px; align-items: flex-start;
|
||
padding: 12px; background: var(--surface);
|
||
border: 1px solid var(--border); border-radius: 8px;
|
||
}
|
||
.step-num {
|
||
flex: 0 0 28px; height: 28px;
|
||
background: var(--accent); color: white; border-radius: 50%;
|
||
display: grid; place-items: center;
|
||
font-weight: 600; font-size: 0.9em;
|
||
}
|
||
.step-body { flex: 1; min-width: 0; }
|
||
.step-body strong { display: block; margin-bottom: 4px; font-size: 0.92em; }
|
||
.step-hint { font-size: 0.78em; color: var(--fg-muted); margin-bottom: 8px; }
|
||
.wifi-credential {
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 8px 10px;
|
||
}
|
||
.wifi-row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 4px 0;
|
||
}
|
||
.wifi-row + .wifi-row { border-top: 1px solid var(--border); }
|
||
.wifi-label {
|
||
flex: 0 0 70px; font-size: 0.72em; color: var(--fg-muted);
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
}
|
||
.wifi-value {
|
||
flex: 1; min-width: 0;
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
font-size: 0.95em; user-select: all; word-break: break-all;
|
||
}
|
||
.copy-btn { flex: 0 0 auto; padding: 4px 8px; font-size: 0.85em; }
|
||
.url-link {
|
||
display: block; padding: 4px 0;
|
||
color: var(--accent); text-decoration: none;
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
font-size: 0.95em;
|
||
}
|
||
.url-link:hover { text-decoration: underline; }
|
||
.recovery-actions {
|
||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||
padding-top: 12px; border-top: 1px solid var(--border);
|
||
}
|
||
.recovery-duration {
|
||
margin-left: auto; font-size: 0.82em; color: var(--fg-muted);
|
||
font-family: ui-monospace, "SF Mono", monospace;
|
||
}
|
||
.recovery-error-msg { font-size: 0.82em; color: var(--alert); margin-top: 4px; }
|
||
|
||
/* ============ Empty state ============ */
|
||
.empty-state {
|
||
text-align: center; padding: 40px 20px;
|
||
color: var(--fg-muted);
|
||
}
|
||
.empty-state .icon { font-size: 2em; margin-bottom: 8px; opacity: 0.5; }
|
||
.empty-state .hint { font-size: 0.85em; margin-top: 4px; }
|
||
|
||
/* ============ Responsive ============ */
|
||
@media (max-width: 900px) {
|
||
.container { padding: 16px; }
|
||
.network-grid { grid-template-columns: 1fr; }
|
||
.app-header { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||
.header-actions { width: 100%; flex-wrap: wrap; }
|
||
}
|
||
|
||
/* ============ Scrollbar ============ */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: var(--bg); }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||
|
||
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
||
|
||
/* ============ Toast-System ============ */
|
||
.toast-container {
|
||
position: fixed; bottom: 24px; right: 24px; z-index: 10000;
|
||
display: flex; flex-direction: column; gap: 8px;
|
||
max-width: 380px; pointer-events: none;
|
||
}
|
||
.toast {
|
||
pointer-events: auto;
|
||
padding: 12px 16px; border-radius: 8px;
|
||
background: var(--surface-2); border: 1px solid var(--border-light);
|
||
color: var(--fg); font-size: 0.9em; line-height: 1.4;
|
||
display: flex; align-items: center; gap: 10px;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||
animation: toastIn 0.25s cubic-bezier(0.2, 0.8, 0.4, 1);
|
||
cursor: pointer;
|
||
}
|
||
.toast:hover { background: var(--surface-3); }
|
||
.toast.removing { animation: toastOut 0.2s ease-out forwards; }
|
||
.toast.success { border-left: 3px solid var(--success); }
|
||
.toast.error { border-left: 3px solid var(--alert); }
|
||
.toast.info { border-left: 3px solid var(--info); }
|
||
.toast.warn { border-left: 3px solid var(--warn); }
|
||
.toast-icon {
|
||
flex: 0 0 18px; height: 18px; display: grid; place-items: center;
|
||
font-weight: bold; font-size: 14px;
|
||
}
|
||
.toast.success .toast-icon { color: var(--success); }
|
||
.toast.error .toast-icon { color: var(--alert); }
|
||
.toast.info .toast-icon { color: var(--info); }
|
||
.toast.warn .toast-icon { color: var(--warn); }
|
||
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
|
||
@keyframes toastIn {
|
||
from { opacity: 0; transform: translateX(20px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
@keyframes toastOut {
|
||
from { opacity: 1; transform: translateX(0); }
|
||
to { opacity: 0; transform: translateX(20px); }
|
||
}
|
||
|
||
/* ============ Modal (Confirm-Dialog) ============ */
|
||
.modal-overlay {
|
||
position: fixed; inset: 0; z-index: 9999;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
backdrop-filter: blur(2px);
|
||
display: flex; align-items: center; justify-content: center;
|
||
animation: fadeIn 0.15s ease;
|
||
}
|
||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||
.modal-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
min-width: 360px; max-width: 480px;
|
||
padding: 24px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||
animation: modalIn 0.2s cubic-bezier(0.2, 0.8, 0.4, 1);
|
||
}
|
||
@keyframes modalIn {
|
||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||
}
|
||
.modal-icon {
|
||
width: 48px; height: 48px;
|
||
border-radius: 12px;
|
||
display: grid; place-items: center;
|
||
margin-bottom: 14px;
|
||
}
|
||
.modal-icon.danger { background: rgba(248, 113, 113, 0.15); color: var(--alert); }
|
||
.modal-icon.warn { background: rgba(251, 191, 36, 0.15); color: var(--warn); }
|
||
.modal-icon.info { background: rgba(109, 142, 255, 0.15); color: var(--accent); }
|
||
.modal-title {
|
||
font-size: 1.15em; font-weight: 600; margin-bottom: 6px;
|
||
}
|
||
.modal-body {
|
||
font-size: 0.9em; color: var(--fg-muted); line-height: 1.5;
|
||
margin-bottom: 20px;
|
||
}
|
||
.modal-actions {
|
||
display: flex; gap: 8px; justify-content: flex-end;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<aside class="sidebar">
|
||
<div class="sidebar-brand">
|
||
<div class="brand-logo">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<line x1="3" y1="9" x2="21" y2="9"/>
|
||
<line x1="9" y1="21" x2="9" y2="9"/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<h1>epaper-dashboard</h1>
|
||
<div class="sub">7.3" ACeP</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-section">Layout</div>
|
||
<button class="sidebar-item active" data-tab="layout" onclick="switchTab('layout')">
|
||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
|
||
Layout
|
||
<span class="badge" id="sbLayout">—</span>
|
||
</button>
|
||
<button class="sidebar-item" data-tab="plugins" onclick="switchTab('plugins')">
|
||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span>
|
||
Plugins
|
||
<span class="badge" id="sbPlugins">—</span>
|
||
</button>
|
||
|
||
<div class="sidebar-section">System</div>
|
||
<button class="sidebar-item" data-tab="settings" onclick="switchTab('settings')">
|
||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||
Settings
|
||
</button>
|
||
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
|
||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
|
||
Network
|
||
<span class="badge" id="sbNet">—</span>
|
||
</button>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="sidebar-status" id="sidebarStatus">
|
||
<div class="mode-row">
|
||
<div class="mode-dot"></div>
|
||
<span id="sbMode">—</span>
|
||
</div>
|
||
<div class="meta" id="sbMeta">—</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="main">
|
||
<header class="topbar">
|
||
<div class="greeting">
|
||
<strong id="topbarTitle">Layout</strong> · <span id="topbarSubtitle">Widget-Positionen auf dem Display</span>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
<div class="topbar-clock">
|
||
<span id="topbarTime">--:--:--</span>
|
||
<span class="small" id="topbarDate">—</span>
|
||
</div>
|
||
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>
|
||
</svg>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="content">
|
||
|
||
<!-- ========== LAYOUT TAB ========== -->
|
||
<div class="tab-content active" id="tab-layout">
|
||
<div class="recovery-alert" id="recoveryAlert" style="display: none;">
|
||
<div class="recovery-icon">
|
||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/>
|
||
</svg>
|
||
</div>
|
||
<div class="recovery-content">
|
||
<div class="recovery-header">
|
||
<div>
|
||
<div class="recovery-title" id="recoveryTitle">WLAN-Verbindung verloren</div>
|
||
<div class="recovery-subtitle" id="recoverySubtitle">Letzte Verbindung: —</div>
|
||
</div>
|
||
<span class="recovery-badge" id="recoveryBadge">OFFLINE</span>
|
||
</div>
|
||
<div class="recovery-steps">
|
||
<div class="step">
|
||
<div class="step-num">1</div>
|
||
<div class="step-body">
|
||
<strong>Auf deinem Handy oder Laptop:</strong>
|
||
<div class="step-hint">Verbinde dich mit diesem WLAN:</div>
|
||
<div class="wifi-credential">
|
||
<div class="wifi-row">
|
||
<span class="wifi-label">SSID</span>
|
||
<code class="wifi-value" id="recoveryApSsid">epaper-recovery</code>
|
||
<button class="ghost icon copy-btn" onclick="copyText('recoveryApSsid')">⧉</button>
|
||
</div>
|
||
<div class="wifi-row">
|
||
<span class="wifi-label">Passwort</span>
|
||
<code class="wifi-value" id="recoveryApPassword">recovery1234</code>
|
||
<button class="ghost icon copy-btn" onclick="copyText('recoveryApPassword')">⧉</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="step">
|
||
<div class="step-num">2</div>
|
||
<div class="step-body">
|
||
<strong>Browser öffnen:</strong>
|
||
<div class="step-hint">Nach der Verbindung:</div>
|
||
<div class="wifi-credential">
|
||
<a class="url-link" href="http://10.42.0.1:8080" id="recoveryApUrl" target="_blank">http://10.42.0.1:8080</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="step">
|
||
<div class="step-num">3</div>
|
||
<div class="step-body">
|
||
<strong>Im Admin-UI:</strong>
|
||
<div class="step-hint">Auf "Network"-Tab, neues WLAN konfigurieren oder "Reconnect".</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="recovery-actions">
|
||
<button class="warn" id="recoveryStartApBtn" onclick="recoveryToggleAp()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/>
|
||
<line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/>
|
||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||
</svg>
|
||
<span id="recoveryApBtnLabel">AP jetzt starten</span>
|
||
</button>
|
||
<button onclick="netReconnect()">Reconnect versuchen</button>
|
||
<span class="recovery-duration" id="recoveryDuration"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Grid Layout <span class="badge">{{ grid.cols }}×{{ grid.rows }} · {{ layout_items|length }} items</span></h2>
|
||
<div class="layout-status" id="layoutStatus"></div>
|
||
</div>
|
||
<div class="layout-toolbar">
|
||
<form class="add-form" onsubmit="return addItem(event)">
|
||
<select id="newItemPlugin" required>
|
||
{% for w in widgets %}
|
||
<option value="{{ w.name }}">{{ w.label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="newItemSize" title="Default-Größe">
|
||
<option value="auto">auto</option>
|
||
{% for sp in size_presets %}
|
||
<option value="{{ sp[0] }}x{{ sp[1] }}">{{ sp[0] }}×{{ sp[1] }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<button type="submit" class="primary">+ Hinzufügen</button>
|
||
</form>
|
||
<button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button>
|
||
<button onclick="saveLayout()" class="primary" title="Layout auf Server speichern">Speichern</button>
|
||
</div>
|
||
<div id="gridPreview" class="grid-preview"></div>
|
||
<div class="layout-hint">
|
||
<span><kbd>Drag</kbd> Item verschieben</span>
|
||
<span><kbd>Ecke ziehen</kbd> Größe ändern</span>
|
||
<span><kbd>X</kbd> Item löschen</span>
|
||
<span><kbd>Auto-Pack</kbd> alle Items optimal anordnen</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-card">
|
||
<div class="preview-header">
|
||
<h2>Live Preview</h2>
|
||
<div class="meta" id="previewMeta">Auto-refresh alle 10s</div>
|
||
</div>
|
||
<img id="previewImg" src="/snapshot.png?t={{ now }}" alt="preview">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========== PLUGINS TAB ========== -->
|
||
<div class="tab-content" id="tab-plugins">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2>Plugin-Konfiguration</h2>
|
||
<div class="meta" id="pluginCount">—</div>
|
||
</div>
|
||
<div id="pluginConfigs"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========== SETTINGS TAB ========== -->
|
||
<div class="tab-content" id="tab-settings">
|
||
<div class="card">
|
||
<div class="card-header"><h2>Refresh-Intervall</h2></div>
|
||
<form method="post" action="/config" style="display: flex; gap: 12px; align-items: end;">
|
||
<div style="flex: 1;">
|
||
<label style="display: block; margin-bottom: 6px; font-size: 0.82em; color: var(--fg-muted);">
|
||
Display-Refresh alle (Sekunden, min. 30 empfohlen)
|
||
</label>
|
||
<input type="number" name="refresh_interval_s" min="30" max="3600"
|
||
value="{{ refresh_interval }}" step="10"
|
||
style="width: 100%; padding: 10px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 1em;">
|
||
</div>
|
||
<button type="submit" class="primary">Speichern</button>
|
||
</form>
|
||
<div class="meta" style="margin-top: 12px;">
|
||
ACeP-Displays vertragen keine schnellen Refreshes. Hersteller empfiehlt ≥180s.
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header"><h2>System</h2></div>
|
||
<dl style="display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0;">
|
||
<dt class="meta">Service:</dt><dd><code style="font-family: ui-monospace, monospace;">epaper-dashboard.service</code></dd>
|
||
<dt class="meta">Logs:</dt><dd><code style="font-family: ui-monospace, monospace;">journalctl -u epaper-dashboard -f</code></dd>
|
||
<dt class="meta">Config:</dt><dd><code style="font-family: ui-monospace, monospace;">config.json</code></dd>
|
||
<dt class="meta">Plugins:</dt><dd><code style="font-family: ui-monospace, monospace;">plugins/</code></dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========== NETWORK TAB ========== -->
|
||
<div class="tab-content" id="tab-network">
|
||
<div class="network-grid">
|
||
<div class="card">
|
||
<h3>Aktueller Status</h3>
|
||
<div id="netCurrent" class="meta">lade…</div>
|
||
<div style="display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;">
|
||
<button onclick="netReconnect()">Reconnect</button>
|
||
<button onclick="netDisconnect()">Disconnect</button>
|
||
<button class="warn" onclick="netForceAp()">Force AP</button>
|
||
<button class="danger" onclick="netStopAp()" id="netStopApBtn" style="display:none;">AP beenden</button>
|
||
</div>
|
||
<div class="help" id="netHint" style="margin-top: 12px;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>WLAN verbinden / wechseln</h3>
|
||
<label>Verfügbare Netzwerke</label>
|
||
<select id="ssidSelect" onchange="document.getElementById('ssidManual').value=this.value">
|
||
<option value="">— bitte wählen —</option>
|
||
</select>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="ssidManualToggle" onchange="document.getElementById('ssidManual').disabled=!this.checked">
|
||
<label for="ssidManualToggle">SSID manuell eingeben</label>
|
||
</div>
|
||
<input type="text" id="ssidManual" placeholder="SSID" disabled>
|
||
<label>Passwort</label>
|
||
<input type="password" id="wifiPwd" placeholder="WPA/WPA2 Passwort">
|
||
<label>Sicherheit</label>
|
||
<select id="wifiSecurity">
|
||
<option value="wpa-psk">WPA/WPA2/WPA3</option>
|
||
<option value="wep">WEP</option>
|
||
<option value="open">Offen</option>
|
||
</select>
|
||
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||
<button class="primary" onclick="netConnect()">Speichern & 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
|
||
function toast(text, type = "info", duration = 3500) {
|
||
const c = document.getElementById('toastContainer');
|
||
const el = document.createElement('div');
|
||
el.className = 'toast ' + type;
|
||
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
|
||
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
|
||
el.querySelector('.toast-text').textContent = text;
|
||
let removed = false;
|
||
const remove = () => {
|
||
if (removed) return;
|
||
removed = true;
|
||
el.classList.add('removing');
|
||
setTimeout(() => el.remove(), 220);
|
||
};
|
||
el.addEventListener('click', remove);
|
||
c.appendChild(el);
|
||
if (duration > 0) setTimeout(remove, duration);
|
||
return remove;
|
||
}
|
||
|
||
// ============ Modal-Confirm-Helper ============
|
||
// Promise-basiert: const ok = await modalConfirm({ title, body, icon, confirmText, cancelText });
|
||
function modalConfirm(opts) {
|
||
return new Promise((resolve) => {
|
||
const icon = opts.icon || 'warn';
|
||
const iconChar = { danger: '🗑', warn: '⚠', info: 'ⓘ' }[icon] || '⚠';
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="modal-card" role="dialog" aria-modal="true">
|
||
<div class="modal-icon ${icon}">${iconChar}</div>
|
||
<div class="modal-title"></div>
|
||
<div class="modal-body"></div>
|
||
<div class="modal-actions">
|
||
<button class="modal-cancel"></button>
|
||
<button class="modal-ok danger"></button>
|
||
</div>
|
||
</div>`;
|
||
overlay.querySelector('.modal-title').textContent = opts.title || 'Bestätigen';
|
||
overlay.querySelector('.modal-body').textContent = opts.body || '';
|
||
overlay.querySelector('.modal-cancel').textContent = opts.cancelText || 'Abbrechen';
|
||
overlay.querySelector('.modal-ok').textContent = opts.confirmText || 'Bestätigen';
|
||
const close = (val) => {
|
||
document.removeEventListener('keydown', onKey);
|
||
overlay.remove();
|
||
resolve(val);
|
||
};
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') close(false);
|
||
if (e.key === 'Enter') close(true);
|
||
};
|
||
overlay.querySelector('.modal-cancel').addEventListener('click', () => close(false));
|
||
overlay.querySelector('.modal-ok').addEventListener('click', () => close(true));
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
|
||
document.addEventListener('keydown', onKey);
|
||
document.body.appendChild(overlay);
|
||
// Focus den OK-Button (default-Action)
|
||
setTimeout(() => overlay.querySelector('.modal-ok').focus(), 50);
|
||
});
|
||
}
|
||
|
||
// ============ Globale Daten ============
|
||
let layoutItems = {{ layout_items|tojson }};
|
||
let pluginConfigs = {{ plugin_configs|tojson }};
|
||
let widgetsMeta = {{ widgets|tojson }};
|
||
const gridCols = {{ grid.cols }};
|
||
const gridRows = {{ grid.rows }};
|
||
const sizePresets = {{ size_presets|tojson }};
|
||
const demoMode = {{ demo_mode|tojson }};
|
||
const urlParams = new URLSearchParams(location.search);
|
||
const demoRecovery = urlParams.get("demo_recovery");
|
||
|
||
function widgetMeta(name) {
|
||
return widgetsMeta.find(w => w.name === name) || { name, label: name, schema: [], defaults: {} };
|
||
}
|
||
|
||
// ============ Tabs (Sidebar + Inhalt) ============
|
||
const TAB_TITLES = {
|
||
layout: ['Layout', 'Widget-Positionen auf dem Display'],
|
||
plugins: ['Plugins', 'Plugin-Configs und Secrets'],
|
||
settings: ['Settings', 'Display-Refresh und System'],
|
||
network: ['Network', 'WLAN-Konfiguration'],
|
||
};
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.sidebar-item').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${name}`));
|
||
if (name === 'plugins') renderPluginConfigs();
|
||
// Topbar-Greeting updaten
|
||
const t = TAB_TITLES[name];
|
||
if (t) {
|
||
document.getElementById('topbarTitle').textContent = t[0];
|
||
document.getElementById('topbarSubtitle').textContent = t[1];
|
||
}
|
||
}
|
||
|
||
// ============ Topbar Clock ============
|
||
function updateClock() {
|
||
const now = new Date();
|
||
const hh = String(now.getHours()).padStart(2, '0');
|
||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||
document.getElementById('topbarTime').textContent = `${hh}:${mm}:${ss}`;
|
||
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||
document.getElementById('topbarDate').textContent = `${days[now.getDay()]} ${now.getDate()}.${String(now.getMonth()+1).padStart(2,'0')}`;
|
||
}
|
||
setInterval(updateClock, 1000);
|
||
updateClock();
|
||
|
||
// ============ DRAG & DROP LAYOUT EDITOR ============
|
||
let saveTimer = null;
|
||
function debouncedSave() {
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(saveLayout, 500);
|
||
}
|
||
|
||
// ---- Which cells are occupied by which item index ----
|
||
function occupiedCells() {
|
||
const map = {}; // cellIdx -> itemIdx
|
||
layoutItems.forEach((it, idx) => {
|
||
for (let dx = 0; dx < it.w; dx++) {
|
||
for (let dy = 0; dy < it.h; dy++) {
|
||
map[(it.x + dx) + ',' + (it.y + dy)] = idx;
|
||
}
|
||
}
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function canPlace(x, y, w, h, ignoreIdx) {
|
||
if (x < 0 || y < 0 || x + w > gridCols || y + h > gridRows) return false;
|
||
const occ = occupiedCells();
|
||
for (let dx = 0; dx < w; dx++) {
|
||
for (let dy = 0; dy < h; dy++) {
|
||
const cellIdx = (x + dx) + ',' + (y + dy);
|
||
const occupier = occ[cellIdx];
|
||
if (occupier !== undefined && occupier !== ignoreIdx) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ---- Render: build cells + items once ----
|
||
function renderGrid() {
|
||
const cont = document.getElementById('gridPreview');
|
||
const cellGap = 6; // px, matches CSS gap
|
||
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap; // account padding+gap
|
||
const cellH = 120; // matches CSS grid-template-rows
|
||
|
||
// Build cell list (always recreate to get accurate measurements)
|
||
cont.innerHTML = '';
|
||
const cellEls = [];
|
||
for (let i = 0; i < gridCols * gridRows; i++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'grid-cell';
|
||
cell.dataset.cell = i;
|
||
cont.appendChild(cell);
|
||
cellEls.push(cell);
|
||
}
|
||
|
||
// Place items into their origin cell
|
||
const occ = occupiedCells();
|
||
layoutItems.forEach((it, idx) => {
|
||
const cellIdx = it.x + ',' + it.y;
|
||
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
|
||
|
||
const div = document.createElement('div');
|
||
const m = widgetMeta(it.plugin);
|
||
div.className = 'grid-item';
|
||
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
|
||
div.dataset.idx = idx;
|
||
div.draggable = true;
|
||
|
||
// 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>
|
||
`;
|
||
|
||
if (originCell) {
|
||
originCell.classList.add('occupied');
|
||
originCell.appendChild(div);
|
||
} else {
|
||
cont.appendChild(div);
|
||
}
|
||
});
|
||
|
||
// Mark occupied cells
|
||
Object.keys(occ).forEach(key => {
|
||
const [cx, cy] = key.split(',').map(Number);
|
||
const cell = cont.querySelector(`[data-cell="${cx + cy * gridCols}"]`);
|
||
if (cell) cell.classList.add('occupied');
|
||
});
|
||
|
||
attachDragHandlers(cont, cellEls);
|
||
updateLayoutStatus();
|
||
}
|
||
|
||
// ---- Drag state ----
|
||
let dragState = null; // { itemIdx, originItem, ghost }
|
||
|
||
function attachDragHandlers(cont, cellEls) {
|
||
// ---- Item: drag start ----
|
||
cont.querySelectorAll('.grid-item').forEach(el => {
|
||
el.addEventListener('click', e => {
|
||
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
|
||
selectItem(parseInt(el.dataset.idx));
|
||
});
|
||
|
||
el.addEventListener('dragstart', e => {
|
||
const itemIdx = parseInt(el.dataset.idx);
|
||
const it = layoutItems[itemIdx];
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', String(itemIdx));
|
||
el.classList.add('dragging');
|
||
|
||
// Transparent 1x1 pixel drag image so browser doesn't show a default ghost
|
||
const empty = document.createElement('canvas');
|
||
empty.width = empty.height = 1;
|
||
e.dataTransfer.setDragImage(empty, 0, 0);
|
||
|
||
// Snapshot for ghost
|
||
dragState = { itemIdx, originItem: { ...it }, ghost: null };
|
||
});
|
||
});
|
||
|
||
// ---- Cell: dragover — show ghost preview ----
|
||
cellEls.forEach(cell => {
|
||
cell.addEventListener('dragover', e => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
|
||
if (!dragState) return;
|
||
const { itemIdx, originItem } = dragState;
|
||
const it = layoutItems[itemIdx];
|
||
const cellIdx = parseInt(cell.dataset.cell);
|
||
const col = cellIdx % gridCols;
|
||
const row = Math.floor(cellIdx / gridCols);
|
||
const tx = Math.max(0, Math.min(gridCols - it.w, col));
|
||
const ty = Math.max(0, Math.min(gridRows - it.h, row));
|
||
const fits = canPlace(tx, ty, it.w, it.h, itemIdx);
|
||
|
||
cell.classList.toggle('drop-target', fits);
|
||
cell.classList.toggle('drop-invalid', !fits);
|
||
|
||
// Ghost: show where item will land
|
||
updateGhost(cont, cellEls, tx, ty, it.w, it.h, itemIdx, fits);
|
||
});
|
||
|
||
cell.addEventListener('dragleave', e => {
|
||
// Only clear if leaving to outside the cell
|
||
if (!cell.contains(e.relatedTarget)) {
|
||
cell.classList.remove('drop-target', 'drop-invalid');
|
||
removeGhost(cont);
|
||
}
|
||
});
|
||
|
||
cell.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
if (!dragState) return;
|
||
const { itemIdx } = dragState;
|
||
const it = layoutItems[itemIdx];
|
||
const cellIdx = parseInt(cell.dataset.cell);
|
||
const col = cellIdx % gridCols;
|
||
const row = Math.floor(cellIdx / gridCols);
|
||
const tx = Math.max(0, Math.min(gridCols - it.w, col));
|
||
const ty = Math.max(0, Math.min(gridRows - it.h, row));
|
||
|
||
if (canPlace(tx, ty, it.w, it.h, itemIdx)) {
|
||
it.x = tx; it.y = ty;
|
||
renderGrid(); // full re-render after drop to reflect new layout
|
||
debouncedSave();
|
||
}
|
||
removeGhost(cont);
|
||
cell.classList.remove('drop-target', 'drop-invalid');
|
||
dragState = null;
|
||
});
|
||
});
|
||
|
||
// ---- Item dragend ----
|
||
cont.querySelectorAll('.grid-item').forEach(el => {
|
||
el.addEventListener('dragend', () => {
|
||
el.classList.remove('dragging');
|
||
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
|
||
removeGhost(cont);
|
||
dragState = null;
|
||
// Re-render to restore any mid-drag state changes (e.g. failed drop)
|
||
renderGrid();
|
||
});
|
||
});
|
||
|
||
// ---- Delete buttons ----
|
||
cont.querySelectorAll('.grid-item-delete').forEach(btn => {
|
||
btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
const idx = parseInt(btn.dataset.idx);
|
||
const item = layoutItems[idx];
|
||
const m = widgetMeta(item.plugin);
|
||
const ok = await modalConfirm({
|
||
icon: 'danger',
|
||
title: 'Item löschen?',
|
||
body: `"${m.label}" wird aus dem Layout entfernt. Diese Aktion kann nicht rückgängig gemacht werden.`,
|
||
confirmText: 'Löschen',
|
||
});
|
||
if (!ok) return;
|
||
layoutItems.splice(idx, 1);
|
||
renderGrid();
|
||
debouncedSave();
|
||
toast(`"${m.label}" gelöscht`, 'success');
|
||
});
|
||
});
|
||
|
||
// ---- Resize handles ----
|
||
cont.querySelectorAll('.grid-item-resize').forEach(h => {
|
||
h.addEventListener('mousedown', startResize);
|
||
});
|
||
|
||
// ---- Keyboard: X to delete selected ----
|
||
document.addEventListener('keydown', onKey);
|
||
}
|
||
|
||
// ---- Ghost preview during drag ----
|
||
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
|
||
removeGhost(cont);
|
||
const targetCell = cont.querySelector(`[data-cell="${x + y * gridCols}"]`);
|
||
if (!targetCell) return;
|
||
const ghost = document.createElement('div');
|
||
ghost.id = 'drag-ghost';
|
||
ghost.style.cssText = `
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 6px;
|
||
pointer-events: none;
|
||
z-index: 20;
|
||
opacity: 0.55;
|
||
border: 2px dashed ${fits ? 'var(--accent)' : 'var(--alert)'};
|
||
background: ${fits ? 'var(--accent-glow)' : 'rgba(248,113,113,0.15)'};
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.8em; color: ${fits ? 'var(--accent)' : 'var(--alert)'};
|
||
`;
|
||
ghost.textContent = fits ? `${w}×${h}` : 'Überlappung!';
|
||
targetCell.appendChild(ghost);
|
||
}
|
||
|
||
function removeGhost(cont) {
|
||
const g = cont.querySelector('#drag-ghost');
|
||
if (g) g.remove();
|
||
}
|
||
|
||
// ---- Resize ----
|
||
function startResize(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const idx = parseInt(e.currentTarget.dataset.resize);
|
||
const it = layoutItems[idx];
|
||
const cellGap = 6;
|
||
const cont = document.getElementById('gridPreview');
|
||
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
|
||
const startX = e.clientX, startY = e.clientY;
|
||
const origW = it.w, origH = it.h;
|
||
|
||
function onMove(ev) {
|
||
const dx = Math.round((ev.clientX - startX) / cellW);
|
||
const dy = Math.round((ev.clientY - startY) / 120);
|
||
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
|
||
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
|
||
if (newW === it.w && newH === it.h) return;
|
||
it.w = newW; it.h = newH;
|
||
renderGrid();
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
debouncedSave();
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function onKey(e) {
|
||
if (e.key === 'x' || e.key === 'X') {
|
||
const sel = document.querySelector('.grid-item.selected');
|
||
if (sel) {
|
||
const idx = parseInt(sel.dataset.idx);
|
||
const item = layoutItems[idx];
|
||
const m = widgetMeta(item.plugin);
|
||
modalConfirm({
|
||
icon: 'danger',
|
||
title: 'Item löschen?',
|
||
body: `"${m.label}" wird aus dem Layout entfernt.`,
|
||
confirmText: 'Löschen',
|
||
}).then(ok => {
|
||
if (!ok) return;
|
||
layoutItems.splice(idx, 1);
|
||
renderGrid();
|
||
debouncedSave();
|
||
toast(`"${m.label}" gelöscht`, 'success');
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function selectItem(idx) {
|
||
document.querySelectorAll('.grid-item').forEach(el => el.classList.remove('selected'));
|
||
const el = document.querySelector(`.grid-item[data-idx="${idx}"]`);
|
||
if (el) el.classList.add('selected');
|
||
}
|
||
|
||
function findOverlaps(items) {
|
||
const out = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const a = items[i];
|
||
const ac = new Set();
|
||
for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy));
|
||
for (let j = i+1; j < items.length; j++) {
|
||
const b = items[j];
|
||
for (let dx = 0; dx < b.w; dx++) for (let dy = 0; dy < b.h; dy++) {
|
||
if (ac.has((b.x+dx)+','+(b.y+dy))) { out.push([i, j]); break; }
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function updateLayoutStatus() {
|
||
const overlaps = findOverlaps(layoutItems);
|
||
const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0);
|
||
const status = document.getElementById('layoutStatus');
|
||
if (status) {
|
||
if (overlaps.length) {
|
||
status.textContent = `${overlaps.length} Überlappung(en)`;
|
||
status.classList.add('has-warnings');
|
||
} else if (oob.length) {
|
||
status.textContent = `${oob.length} außerhalb`;
|
||
status.classList.add('has-warnings');
|
||
} else {
|
||
status.textContent = `${layoutItems.length} items, OK`;
|
||
status.classList.remove('has-warnings');
|
||
}
|
||
}
|
||
const used = new Set(layoutItems.map(it => it.plugin));
|
||
const pc = document.getElementById('pluginCount');
|
||
if (pc) pc.textContent = `${used.size} Plugins konfiguriert`;
|
||
const sl = document.getElementById('sbLayout');
|
||
if (sl) sl.textContent = layoutItems.length;
|
||
const sp = document.getElementById('sbPlugins');
|
||
if (sp) sp.textContent = used.size;
|
||
}
|
||
|
||
async function addItem(ev) {
|
||
ev.preventDefault();
|
||
const plugin = document.getElementById('newItemPlugin').value;
|
||
const sizeSel = document.getElementById('newItemSize').value;
|
||
const fd = new FormData();
|
||
fd.append('plugin', plugin);
|
||
if (sizeSel !== 'auto') {
|
||
const [w, h] = sizeSel.split('x').map(Number);
|
||
fd.append('w', w); fd.append('h', h);
|
||
}
|
||
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (!j.ok) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
toast('Widget hinzugefügt', 'success', 2000);
|
||
}
|
||
async function saveLayout() {
|
||
const r = await fetch('/api/layout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ items: layoutItems }),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) { toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
||
layoutItems = j.items;
|
||
renderGrid();
|
||
setTimeout(refreshPreview, 500);
|
||
toast('Layout gespeichert', 'success');
|
||
}
|
||
async function packAll() {
|
||
const ok = await modalConfirm({
|
||
icon: 'warn',
|
||
title: 'Alle Items neu anordnen?',
|
||
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.',
|
||
confirmText: 'Neu anordnen',
|
||
});
|
||
if (!ok) return;
|
||
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();
|
||
toast('Layout automatisch angeordnet', 'success');
|
||
}
|
||
|
||
// ============ 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>
|