morz-infoboard/docs/superpowers/plans/2026-03-24-frontend-overhaul.md
Alwin f435c8aeaf docs: Frontend-Overhaul-Implementierungsplan hinzugefügt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:08:34 +00:00

1940 lines
93 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Frontend Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign all six server-rendered HTML templates to be professional, polished, and fun to use — with MORZ red accent, system-UI font, and interactive inline editing on the playlist page.
**Architecture:** All templates live as Go `const` string variables in two files. Bulma CSS is customized via CSS custom properties. Inline JS handles interactivity; no new libraries, no build step. One small Go handler change makes `HandleUpdateItemUI` return `204` for fetch callers.
**Tech Stack:** Go html/template, Bulma CSS (local `/static/bulma.min.css`), SortableJS (local `/static/Sortable.min.js`), vanilla JS ES5+
---
## Shared CSS Reference
Every template gets this `<style>` block (copy verbatim, adjust page-specific additions below it):
```css
<style>
:root {
--morz-red: #E30613;
--morz-red-dark: #B8000F;
--nav-bg: #1A1A2E;
--bg: #F7F8FA;
--surface: #FFFFFF;
--shadow-sm: 0 1px 4px rgba(0,0,0,.08);
--shadow-md: 0 4px 16px rgba(0,0,0,.12);
--radius: 8px;
--radius-btn: 6px;
--bulma-primary-h: 352deg;
--bulma-primary-s: 96%;
--bulma-primary-l: 44%;
}
body { background: var(--bg); font-family: system-ui, -apple-system, "Segoe UI", sans-serif; min-height: 100vh; }
.navbar { background: var(--nav-bg) !important; }
.navbar-item, .navbar-link { color: rgba(255,255,255,.85) !important; }
.navbar-item:hover, .navbar-link:hover { background: rgba(255,255,255,.08) !important; color: #fff !important; }
.morz-brand { font-weight: 800; font-size: 1.1rem; }
.morz-brand .accent { color: var(--morz-red); }
.card, .box { border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.button.is-primary { background: var(--morz-red) !important; border-color: var(--morz-red) !important; border-radius: var(--radius-btn); }
.button.is-primary:hover { background: var(--morz-red-dark) !important; border-color: var(--morz-red-dark) !important; }
.tabs.is-underline li.is-active a { border-bottom-color: var(--morz-red) !important; color: var(--morz-red) !important; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; vertical-align: middle; }
.status-dot.online { background: #22c55e; }
.status-dot.stale { background: #f59e0b; }
.status-dot.offline { background: #ef4444; }
.status-dot.unknown { background: #9ca3af; }
/* Toast */
.morz-toast { position: fixed; top: 1rem; right: 1rem; z-index: 9999; max-width: 380px;
border-radius: 24px; box-shadow: var(--shadow-md); padding: .75rem 1.25rem;
display: flex; align-items: center; gap: .75rem; font-size: .9rem;
transform: translateX(120%); transition: transform .25s ease; }
.morz-toast.show { transform: translateX(0); }
.morz-toast.is-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.morz-toast.is-danger { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.morz-toast.is-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
</style>
```
Shared JS toast helper (include in every template before closing `</body>`):
```js
function showToast(msg, type) {
type = type || 'is-success';
var t = document.createElement('div');
t.className = 'morz-toast ' + type;
t.innerHTML = msg + '<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
document.body.appendChild(t);
requestAnimationFrame(function() { t.classList.add('show'); });
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
}
```
Shared status-polling helper:
```js
function pollScreenStatus(slugToId) {
// slugToId: object mapping screen_id -> DOM element id prefix (e.g. 'status-')
function update() {
fetch('/api/v1/screens/status')
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data || !data.screens) return;
data.screens.forEach(function(s) {
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var state = s.derived_state || 'unknown';
el.className = 'status-dot ' + (state === 'online' ? 'online' : state === 'degraded' ? 'stale' : 'offline');
el.title = state;
});
}).catch(function(){});
}
update();
setInterval(update, 30000);
}
```
Shared CSRF helper:
```js
function getCsrfToken() {
var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCsrfToken();
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectCSRF);
else injectCSRF();
```
---
## File Map
| File | What changes |
|---|---|
| `server/backend/internal/httpapi/manage/templates.go` | Rewrite all 5 template consts: `loginTmpl`, `provisionTmpl`, `adminTmpl`, `manageTmpl`, `screenOverviewTmpl` |
| `server/backend/internal/httpapi/tenant/templates.go` | Rewrite `tenantDashTmpl` |
| `server/backend/internal/httpapi/manage/ui.go` | `HandleUpdateItemUI`: return 204 when `X-Requested-With: fetch` header present |
---
## Task 1: HandleUpdateItemUI — return 204 for fetch callers
**Files:**
- Modify: `server/backend/internal/httpapi/manage/ui.go:781-788`
- Test: `server/backend/internal/httpapi/router_test.go` (add test)
The handler currently always redirects. Inline editing JS will call it via `fetch`, which needs a 204 back (not a redirect).
- [ ] **Step 1: Find the redirect line**
In `ui.go`, the last line of `HandleUpdateItemUI` is:
```go
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther)
```
- [ ] **Step 2: Replace with conditional response**
Replace that redirect with:
```go
if r.Header.Get("X-Requested-With") == "fetch" {
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther)
```
- [ ] **Step 3: Build to verify**
```bash
cd server/backend && go build ./...
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add server/backend/internal/httpapi/manage/ui.go
git commit -m "fix(manage): HandleUpdateItemUI returns 204 for fetch callers"
```
---
## Task 2: Login page (`loginTmpl`)
**Files:**
- Modify: `server/backend/internal/httpapi/manage/templates.go` (replace `loginTmpl` const, lines 399)
- [ ] **Step 1: Replace `loginTmpl`**
Replace the entire `loginTmpl` const with:
```go
const loginTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden MORZ Infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; min-height:100vh; display:flex; align-items:center; justify-content:center; padding:1rem; }
.login-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-md); width:100%; max-width:400px; padding:2.5rem 2rem; border-top:4px solid var(--morz-red); }
.login-title { font-size:1.5rem; font-weight:800; text-align:center; margin-bottom:2rem; letter-spacing:-.02em; }
.login-title .accent { color:var(--morz-red); }
.field label { font-weight:600; font-size:.875rem; margin-bottom:.35rem; display:block; color:#374151; }
.input:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.15); outline:none; }
.btn-login { background:var(--morz-red); color:#fff; border:none; border-radius:var(--radius-btn); width:100%; padding:.75rem; font-weight:700; font-size:1rem; cursor:pointer; transition:background .15s; margin-top:1.5rem; }
.btn-login:hover { background:var(--morz-red-dark); }
.error-banner { background:#fef2f2; border:1px solid #fecaca; color:#991b1b; border-radius:6px; padding:.75rem 1rem; font-size:.875rem; margin-bottom:1.25rem; }
.pw-wrap { position:relative; }
.pw-toggle { position:absolute; right:.75rem; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; color:#9ca3af; padding:0; line-height:1; }
.pw-toggle:hover { color:#374151; }
</style>
</head>
<body>
<div class="login-card">
<div class="login-title"><span class="accent">MORZ</span> Infoboard</div>
{{if .Error}}
<div class="error-banner" role="alert">{{.Error}}</div>
{{end}}
<form method="POST" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="field" style="margin-bottom:1rem">
<label for="username">Benutzername</label>
<input class="input" type="text" id="username" name="username"
autocomplete="username" autofocus required
style="border-radius:6px">
</div>
<div class="field">
<label for="password">Passwort</label>
<div class="pw-wrap">
<input class="input" type="password" id="password" name="password"
autocomplete="current-password" required
style="border-radius:6px;padding-right:2.5rem">
<button type="button" class="pw-toggle" onclick="var i=document.getElementById('password');i.type=i.type==='password'?'text':'password'" aria-label="Passwort anzeigen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<button class="btn-login" type="submit">Anmelden</button>
</form>
</div>
</body>
</html>`
```
- [ ] **Step 2: Build**
```bash
cd server/backend && go build ./...
```
- [ ] **Step 3: Commit**
```bash
git add server/backend/internal/httpapi/manage/templates.go
git commit -m "feat(ui): Login-Seite neu gestaltet"
```
---
## Task 3: Provision wizard (`provisionTmpl`)
**Files:**
- Modify: `server/backend/internal/httpapi/manage/templates.go` (replace `provisionTmpl` const, lines 101256)
- [ ] **Step 1: Replace `provisionTmpl`**
```go
const provisionTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichten {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; }
.navbar { background:var(--nav-bg) !important; }
.navbar-item { color:rgba(255,255,255,.85) !important; }
.navbar-item:hover { background:rgba(255,255,255,.08) !important; color:#fff !important; }
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
.step-num { width:2.25rem; height:2.25rem; border-radius:50%; background:var(--morz-red); color:#fff;
display:inline-flex; align-items:center; justify-content:center; font-weight:800;
font-size:.95rem; flex-shrink:0; }
.step-row { display:flex; gap:1.25rem; align-items:flex-start; }
.step-body { flex:1; }
pre { background:#0f172a; color:#e2e8f0; padding:1rem 1.25rem; border-radius:6px; font-size:.85rem;
line-height:1.6; overflow-x:auto; margin:.75rem 0; }
code { background:#f1f5f9; color:#1e293b; padding:.1em .35em; border-radius:4px; font-size:.875em; }
.copy-btn { font-size:.75rem; cursor:pointer; border-radius:4px; }
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
.button.is-primary:hover { background:var(--morz-red-dark) !important; border-color:var(--morz-red-dark) !important; }
.success-banner { background:#f0fdf4; border:1px solid #bbf7d0; color:#166534; border-radius:var(--radius); padding:1rem 1.25rem; margin-bottom:1.5rem; }
</style>
</head>
<body>
<nav class="navbar" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/admin">← Admin</a>
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
</div>
</nav>
<section class="section">
<div class="container" style="max-width:820px">
<div class="success-banner">
<strong>✓ Screen «{{.Screen.Name}}» ({{.Screen.Slug}}) wurde angelegt.</strong><br>
Führe die folgenden Schritte aus, um den Bildschirm zu provisionieren.
</div>
<div class="box mb-4">
<div class="step-row">
<span class="step-num">1</span>
<div class="step-body">
<p class="has-text-weight-semibold mb-2">Host zur Ansible-Inventardatei hinzufügen</p>
<p class="mb-2 has-text-grey is-size-7">Öffne <code>ansible/inventory.yml</code> und füge ein:</p>
<pre id="inv"> {{.Screen.Slug}}:</pre>
<button class="button is-small is-light copy-btn" onclick="copyEl('inv',this)">📋 Kopieren</button>
</div>
</div>
</div>
<div class="box mb-4">
<div class="step-row">
<span class="step-num">2</span>
<div class="step-body">
<p class="has-text-weight-semibold mb-2">Host-Variablen anlegen</p>
<p class="mb-2 has-text-grey is-size-7">Erstelle <code>ansible/host_vars/{{.Screen.Slug}}/vars.yml</code>:</p>
<pre id="hostvars">---
ansible_host: {{.IP}}
ansible_user: {{.SSHUser}}
screen_id: {{.Screen.Slug}}
screen_name: "{{.Screen.Name}}"
screen_orientation: {{.Orientation}}</pre>
<div class="buttons mt-2">
<button class="button is-small is-light copy-btn" id="btn-hostvars" onclick="copyEl('hostvars','btn-hostvars')">📋 Kopieren</button>
<button class="button is-small is-light" onclick="dlFile(document.getElementById('hostvars').innerText,'vars.yml')">⬇ Herunterladen</button>
</div>
<p class="help mt-1">Tipp: <code>mkdir -p ansible/host_vars/{{.Screen.Slug}}</code></p>
</div>
</div>
</div>
<div class="box mb-4">
<div class="step-row">
<span class="step-num">3</span>
<div class="step-body">
<p class="has-text-weight-semibold mb-2">SSH-Zugang sicherstellen</p>
<pre id="sshcopy">ssh-copy-id {{.SSHUser}}@{{.IP}}</pre>
<button class="button is-small is-light copy-btn" onclick="copyEl('sshcopy',this)">📋 Kopieren</button>
</div>
</div>
</div>
<div class="box mb-4">
<div class="step-row">
<span class="step-num">4</span>
<div class="step-body">
<p class="has-text-weight-semibold mb-2">Ansible-Playbook ausführen</p>
<pre id="playcmd">cd /path/to/morz-infoboard
ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slug}}</pre>
<button class="button is-small is-light copy-btn" onclick="copyEl('playcmd',this)">📋 Kopieren</button>
<p class="help mt-1">Mit Vault: <code>--vault-password-file ansible/.vault_pass</code></p>
</div>
</div>
</div>
<div class="box" style="border-left:4px solid #22c55e">
<div class="step-row">
<span class="step-num" style="background:#22c55e">5</span>
<div class="step-body">
<p class="has-text-weight-semibold mb-2">Fertig — Playlist befüllen</p>
<p class="mb-3 has-text-grey">Nach dem Ansible-Lauf meldet sich der Bildschirm automatisch an.</p>
<div class="buttons">
<a class="button is-primary" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten →</a>
<a class="button" href="/admin">← Zurück zu Admin</a>
</div>
</div>
</div>
</div>
</div>
</section>
<script>
function copyEl(id, btn) {
navigator.clipboard.writeText(document.getElementById(id).innerText).then(function() {
var b = typeof btn === 'string' ? document.getElementById(btn) : btn;
if (!b) return;
var orig = b.textContent; b.textContent = '✓ Kopiert!';
setTimeout(function() { b.textContent = orig; }, 1500);
});
}
function dlFile(content, name) {
var a = document.createElement('a');
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(content);
a.download = name; a.click();
}
</script>
</body>
</html>`
```
- [ ] **Step 2: Build & commit**
```bash
cd server/backend && go build ./... && git add server/backend/internal/httpapi/manage/templates.go && git commit -m "feat(ui): Provision-Wizard neu gestaltet"
```
---
## Task 4: Admin dashboard (`adminTmpl`)
**Files:**
- Modify: `server/backend/internal/httpapi/manage/templates.go` (replace `adminTmpl` const, lines 258877)
This is the largest template in the admin area. Key changes:
- Screen list becomes a card grid (not a table)
- Underline-style tabs (not boxed)
- Polished modals
- Status dots via CSS (not emoji)
- Shared CSS tokens
- [ ] **Step 1: Replace `adminTmpl`**
```go
const adminTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin MORZ Infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; }
.navbar { background:var(--nav-bg) !important; }
.navbar-item,.navbar-link { color:rgba(255,255,255,.85) !important; }
.navbar-item:hover,.navbar-link:hover { background:rgba(255,255,255,.08) !important; color:#fff !important; }
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
.button.is-primary:hover { background:var(--morz-red-dark) !important; border-color:var(--morz-red-dark) !important; }
.button { border-radius:var(--radius-btn) !important; }
/* Underline tabs */
.tabs li.is-active a { border-bottom:3px solid var(--morz-red) !important; color:var(--morz-red) !important; font-weight:600; }
.tabs a { border-bottom:3px solid transparent; color:#374151; }
.tab-panel { display:none; }
.tab-panel.is-active { display:block; }
/* Screen cards */
.screen-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:1.25rem; display:flex; flex-direction:column; gap:.75rem; transition:box-shadow .15s; }
.screen-card:hover { box-shadow:var(--shadow-md); }
.screen-card-name { font-weight:700; font-size:1rem; display:flex; align-items:center; gap:.5rem; }
.screen-card-slug { font-family:monospace; font-size:.8rem; color:#6b7280; }
.screen-card-meta { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
.screen-card-actions { display:flex; gap:.5rem; margin-top:auto; }
.orient-badge { font-size:.7rem; background:#f3f4f6; color:#374151; padding:.2em .6em; border-radius:99px; font-weight:600; }
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
.status-dot.online { background:#22c55e; }
.status-dot.stale { background:#f59e0b; }
.status-dot.offline { background:#ef4444; }
.status-dot.unknown { background:#9ca3af; }
/* Modal */
.modal-card { border-radius:var(--radius); overflow:hidden; }
.modal-card-head { background:var(--nav-bg); }
.modal-card-title { color:#fff; font-weight:700; }
.modal-card-head .delete { background:rgba(255,255,255,.2); }
/* Toasts */
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px;
border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem;
display:flex; align-items:center; gap:.75rem; font-size:.9rem;
transform:translateX(120%); transition:transform .25s ease; }
.morz-toast.show { transform:translateX(0); }
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
.morz-toast.is-warning { background:#fffbeb; color:#92400e; border:1px solid #fde68a; }
/* Form polish */
.input:focus,.select select:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.12); outline:none; }
details summary { cursor:pointer; user-select:none; }
</style>
</head>
<body>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="adminNav">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</a>
</div>
<div id="adminNav" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/status">Diagnose</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Delete screen modal -->
<div id="del-modal" class="modal">
<div class="modal-background" onclick="closeModal('del-modal')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Bildschirm löschen?</p>
<button class="delete" onclick="closeModal('del-modal')"></button>
</header>
<section class="modal-card-body">
<p>Soll <strong id="del-name"></strong> wirklich gelöscht werden?</p>
<p class="help has-text-grey mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
</section>
<footer class="modal-card-foot" style="gap:.5rem">
<form id="del-form" method="POST"><button class="button is-danger" type="submit">Wirklich löschen</button></form>
<button class="button" onclick="closeModal('del-modal')">Abbrechen</button>
</footer>
</div>
</div>
<!-- Delete user modal -->
<div id="del-user-modal" class="modal">
<div class="modal-background" onclick="closeModal('del-user-modal')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Benutzer löschen?</p>
<button class="delete" onclick="closeModal('del-user-modal')"></button>
</header>
<section class="modal-card-body">
<p>Soll <strong id="del-user-name"></strong> wirklich gelöscht werden?</p>
<p class="help has-text-grey mt-2">Alle Screen-Zuordnungen werden entfernt.</p>
</section>
<footer class="modal-card-foot" style="gap:.5rem">
<form id="del-user-form" method="POST"><button class="button is-danger" type="submit">Wirklich löschen</button></form>
<button class="button" onclick="closeModal('del-user-modal')">Abbrechen</button>
</footer>
</div>
</div>
<!-- Screen users modal -->
<div id="screen-users-modal" class="modal">
<div class="modal-background" onclick="closeModal('screen-users-modal')"></div>
<div class="modal-card" style="width:580px;max-width:95vw">
<header class="modal-card-head">
<p class="modal-card-title" id="su-modal-title">Benutzer verwalten</p>
<button class="delete" onclick="closeModal('screen-users-modal')"></button>
</header>
<section class="modal-card-body" id="su-modal-body"></section>
<footer class="modal-card-foot">
<button class="button" onclick="closeModal('screen-users-modal')">Schließen</button>
</footer>
</div>
</div>
<section class="section pb-0 pt-4">
<div class="container">
<nav class="breadcrumb mb-3"><ul><li class="is-active"><a>Admin</a></li></ul></nav>
<div class="tabs mb-0">
<ul>
<li id="tab-screens" class="{{if eq .ActiveTab "screens"}}is-active{{end}}">
<a onclick="switchTab('screens')" style="cursor:pointer">Bildschirme</a>
</li>
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
<a onclick="switchTab('users')" style="cursor:pointer">Benutzer</a>
</li>
</ul>
</div>
</div>
</section>
<section class="section pt-3">
<div class="container">
<!-- Panel: Screens -->
<div id="panel-screens" class="tab-panel{{if eq .ActiveTab "screens"}} is-active{{end}}">
<div class="box mb-4">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.75rem;margin-bottom:1.25rem">
<h2 class="title is-5 mb-0">Bildschirme</h2>
<div class="buttons mb-0">
<a class="button is-primary is-small" href="#add-screen">+ Neuer Bildschirm</a>
</div>
</div>
{{if .Screens}}
<div class="columns is-multiline">
{{range .Screens}}
{{$users := index $.ScreenUserMap .ID}}
<div class="column is-4-desktop is-6-tablet">
<div class="screen-card">
<div class="screen-card-name">
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
{{.Name}}
</div>
<div class="screen-card-slug">{{.Slug}}</div>
<div class="screen-card-meta">
<span class="orient-badge">{{orientationLabel .Orientation}}</span>
<span id="status-{{.Slug}}" class="status-dot unknown" title="Unbekannt"></span>
<button class="button is-small is-light" type="button"
data-screen-id="{{.ID}}" data-screen-name="{{.Name}}"
onclick="openScreenUsersModal('{{.ID}}', {{.Name | printf "%q"}}, buildScreenUsersHTML('{{.ID}}', {{.Name | printf "%q"}}))">
{{len $users}} Benutzer
</button>
</div>
<div class="screen-card-actions">
<a class="button is-small is-primary is-fullwidth" href="/manage/{{.Slug}}">Playlist</a>
<button class="button is-small is-danger is-outlined" type="button"
onclick="openDelModal('/admin/screens/{{.ID}}/delete','{{.Name}}')">Löschen</button>
</div>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="notification is-light">Noch keine Bildschirme angelegt.</div>
{{end}}
</div>
<!-- Add screen form -->
<div class="box" id="add-screen">
<h2 class="title is-5 mb-1">Neuen Bildschirm einrichten</h2>
<p class="has-text-grey mb-4" style="font-size:.875rem">Bildschirm anlegen und Ansible-Deployment-Anleitung generieren.</p>
<form method="POST" action="/admin/screens/provision">
<div class="columns is-multiline">
<div class="column is-3">
<div class="field">
<label class="label is-small">Slug / Hostname</label>
<input class="input is-small" type="text" name="slug" placeholder="z.B. info12" required pattern="[a-z0-9-]+" title="Kleinbuchstaben, Zahlen, Bindestriche">
<p class="help">Als <code>screen_id</code> verwendet</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label is-small">Anzeigename</label>
<input class="input is-small" type="text" name="name" placeholder="z.B. Kantine EG" required>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label is-small">IP-Adresse</label>
<input class="input is-small" type="text" name="ip" placeholder="10.0.0.X" required>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label is-small">SSH-User</label>
<input class="input is-small" type="text" name="ssh_user" placeholder="morz" value="morz">
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label is-small">Format</label>
<div class="select is-small is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
<button class="button is-primary is-small" type="submit">Anlegen &amp; Anleitung generieren →</button>
</form>
<details class="mt-4">
<summary class="has-text-grey is-size-7" style="cursor:pointer">Nur anlegen (ohne Deployment)</summary>
<form method="POST" action="/admin/screens" class="mt-3">
<div class="columns is-vcentered">
<div class="column is-3">
<div class="field">
<label class="label is-small">Slug</label>
<input class="input is-small" type="text" name="slug" placeholder="flur-eg" required pattern="[a-z0-9-]+">
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label is-small">Name</label>
<input class="input is-small" type="text" name="name" placeholder="Flur Erdgeschoss" required>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label is-small">Format</label>
<div class="select is-small is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label is-small">&nbsp;</label>
<button class="button is-outlined is-small is-fullwidth" type="submit">Nur anlegen</button>
</div>
</div>
</div>
</form>
</details>
</div>
</div><!-- /panel-screens -->
<!-- Panel: Users -->
<div id="panel-users" class="tab-panel{{if eq .ActiveTab "users"}} is-active{{end}}">
<div class="box">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.75rem;margin-bottom:1.25rem">
<div>
<h2 class="title is-5 mb-0">Screen-Benutzer</h2>
<p class="has-text-grey mt-1" style="font-size:.875rem">Können sich einloggen und nur ihre zugeordneten Bildschirme verwalten.</p>
</div>
</div>
{{if .ScreenUsers}}
<div style="overflow-x:auto;margin-bottom:1.5rem">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr style="font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280">
<th>Benutzername</th>
<th>Erstellt</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .ScreenUsers}}
<tr>
<td><strong>{{.Username}}</strong></td>
<td class="has-text-grey" style="font-size:.875rem">{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td style="text-align:right">
<button class="button is-small is-danger is-outlined" type="button"
onclick="openDelUserModal('/admin/users/{{.ID}}/delete','{{.Username}}')">Löschen</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="notification is-light mb-4">Noch keine Benutzer angelegt.</div>
{{end}}
<hr>
<h3 class="title is-6 mb-3">Neuen Benutzer anlegen</h3>
<form method="POST" action="/admin/users">
<div class="columns is-vcentered">
<div class="column is-4">
<div class="field">
<label class="label is-small">Benutzername</label>
<input class="input is-small" type="text" name="username" placeholder="z.B. alice" required autocomplete="off">
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label is-small">Passwort</label>
<div class="control has-icons-right">
<input class="input is-small" type="password" id="admin-new-password" name="password" placeholder="Mindestens 8 Zeichen" required autocomplete="new-password" minlength="8">
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="var i=document.getElementById('admin-new-password');i.type=i.type==='password'?'text':'password'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</span>
</div>
<p class="help">Mind. 8 Zeichen</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label is-small">&nbsp;</label>
<button class="button is-primary is-small" type="submit">Benutzer anlegen</button>
</div>
</div>
</div>
</form>
</div>
</div><!-- /panel-users -->
</div>
</section>
<script>
var _screenUsers = {{.ScreenUsers | screenUsersJSON}};
var _screenUserMap = {{.ScreenUserMap | screenUserMapJSON}};
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function openDelModal(action, name) {
document.getElementById('del-form').action = action;
document.getElementById('del-name').textContent = name;
document.getElementById('del-modal').classList.add('is-active');
}
function openDelUserModal(action, name) {
document.getElementById('del-user-form').action = action;
document.getElementById('del-user-name').textContent = name;
document.getElementById('del-user-modal').classList.add('is-active');
}
function openScreenUsersModal(screenId, screenName, html) {
document.getElementById('su-modal-title').textContent = 'Benutzer: ' + screenName;
document.getElementById('su-modal-body').innerHTML = html;
document.getElementById('screen-users-modal').classList.add('is-active');
injectCSRF();
}
function closeModal(id) { document.getElementById(id).classList.remove('is-active'); }
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') ['del-modal','del-user-modal','screen-users-modal'].forEach(function(id) { closeModal(id); });
});
function buildScreenUsersHTML(screenId, screenName) {
var users = _screenUserMap[screenId] || [];
var allUsers = _screenUsers || [];
var assigned = {};
users.forEach(function(u) { assigned[u.id] = true; });
var html = '';
if (users.length > 0) {
html += '<table class="table is-fullwidth is-narrow mb-4"><thead><tr><th>Benutzer</th><th></th></tr></thead><tbody>';
users.forEach(function(u) {
html += '<tr><td>' + escHtml(u.username) + '</td><td>';
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users/' + escHtml(u.id) + '/remove" style="display:inline">';
html += '<button class="button is-small is-danger is-outlined" type="submit">Entfernen</button></form></td></tr>';
});
html += '</tbody></table>';
} else {
html += '<p class="has-text-grey mb-4">Noch keine Benutzer zugeordnet.</p>';
}
var available = allUsers.filter(function(u) { return !assigned[u.id]; });
if (available.length > 0) {
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users">';
html += '<div class="field has-addons">';
html += '<div class="control is-expanded"><div class="select is-fullwidth"><select name="user_id">';
available.forEach(function(u) { html += '<option value="' + escHtml(u.id) + '">' + escHtml(u.username) + '</option>'; });
html += '</select></div></div>';
html += '<div class="control"><button class="button is-primary" type="submit">Hinzufügen</button></div>';
html += '</div></form>';
} else if (allUsers.length === 0) {
html += '<p class="help has-text-grey">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
} else {
html += '<p class="help has-text-grey">Alle Benutzer sind bereits zugeordnet.</p>';
}
return html;
}
function switchTab(name) {
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tabs li').forEach(function(li) { li.classList.remove('is-active'); });
document.getElementById('panel-' + name).classList.add('is-active');
document.getElementById('tab-' + name).classList.add('is-active');
history.replaceState(null, '', '?tab=' + name);
}
// Navbar burger
(function() {
var b = document.querySelector('.navbar-burger');
if (b) b.addEventListener('click', function() {
b.classList.toggle('is-active');
document.getElementById(b.dataset.target).classList.toggle('is-active');
});
})();
// Toast from ?msg=
(function() {
var msg = new URLSearchParams(window.location.search).get('msg');
if (!msg) return;
var texts = { uploaded:'✓ Medium hochgeladen.',deleted:'✓ Gelöscht.',saved:'✓ Gespeichert.',added:'✓ Hinzugefügt.',user_added:'✓ Benutzer angelegt.',user_deleted:'✓ Benutzer gelöscht.',user_added_to_screen:'✓ Benutzer zugeordnet.',user_removed_from_screen:'✓ Benutzer entfernt.',error_empty:'⚠ Felder ausfüllen.',error_exists:'⚠ Bereits vergeben.',error_db:'⚠ Datenbankfehler.' };
var isErr = msg.startsWith('error_');
showToast(texts[msg] || '✓ Aktion erfolgreich.', isErr ? 'is-warning' : 'is-success');
history.replaceState(null, '', window.location.pathname + (window.location.search.replace(/[?&]msg=[^&]*/,'') || ''));
})();
// Status dots
(function() {
function update() {
fetch('/api/v1/screens/status').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
if (!data || !data.screens) return;
data.screens.forEach(function(s) {
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var state = s.derived_state || 'unknown';
el.className = 'status-dot ' + (state === 'online' ? 'online' : state === 'degraded' ? 'stale' : 'offline');
el.title = state;
});
}).catch(function(){});
}
update(); setInterval(update, 30000);
})();
// Auto-open screen users modal from ?screen=
document.addEventListener('DOMContentLoaded', function() {
var sc = new URLSearchParams(window.location.search).get('screen');
if (!sc) return;
var btn = document.querySelector('[data-screen-id="' + sc + '"]');
if (btn) openScreenUsersModal(sc, btn.getAttribute('data-screen-name') || sc, buildScreenUsersHTML(sc, btn.getAttribute('data-screen-name') || sc));
history.replaceState(null, '', window.location.href.replace(/[?&]screen=[^&]*/,''));
});
function showToast(msg, type) {
type = type || 'is-success';
var t = document.createElement('div');
t.className = 'morz-toast ' + type;
t.innerHTML = msg + '<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
document.body.appendChild(t);
requestAnimationFrame(function() { t.classList.add('show'); });
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
}
function getCsrfToken() { var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)'); return m ? decodeURIComponent(m[1]) : ''; }
function injectCSRF() {
var token = getCsrfToken(); if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) { var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=token;f.appendChild(i); }
});
}
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
</script>
</body>
</html>`
```
- [ ] **Step 2: Build & commit**
```bash
cd server/backend && go build ./... && git add server/backend/internal/httpapi/manage/templates.go && git commit -m "feat(ui): Admin-Dashboard neu gestaltet (Karten-Grid, Tabs, Modals)"
```
---
## Task 5: Manage / Playlist editor (`manageTmpl`)
**Files:**
- Modify: `server/backend/internal/httpapi/manage/templates.go` (replace `manageTmpl` const, lines 8791502)
Key changes from current:
- Two-column layout (playlist 60% / library 40%) on desktop, stacked on mobile
- Playlist items: draggable cards (not table rows), inline-editable title/duration, pill toggle for enabled/disabled, collapsible validity dates
- Library: card grid (not table), "Hinzufügen" CTA per card
- Upload: collapsed by default, expandable
- `HandleUpdateItemUI` called via `fetch` (returns 204)
- CSRF token sent via hidden field in FormData for fetch calls
- [ ] **Step 1: Replace `manageTmpl`**
```go
const manageTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<script src="/static/Sortable.min.js"></script>
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; }
.navbar { background:var(--nav-bg) !important; }
.navbar-item,.navbar-link { color:rgba(255,255,255,.85) !important; }
.navbar-item:hover,.navbar-link:hover { background:rgba(255,255,255,.08) !important; color:#fff !important; }
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
.button { border-radius:var(--radius-btn) !important; }
.input:focus,.select select:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.12); outline:none; }
/* Two-column layout */
.manage-layout { display:grid; grid-template-columns:1fr 380px; gap:1.5rem; align-items:start; }
@media (max-width:900px) { .manage-layout { grid-template-columns:1fr; } }
/* Playlist item card */
.pl-item { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm);
padding:.9rem 1rem; display:flex; align-items:flex-start; gap:.75rem;
transition:box-shadow .15s; position:relative; }
.pl-item:hover { box-shadow:var(--shadow-md); }
.pl-item.is-disabled { opacity:.45; }
.pl-item + .pl-item { margin-top:.5rem; }
.drag-handle { cursor:grab; color:#d1d5db; font-size:1.1rem; padding:.1rem .15rem; user-select:none; flex-shrink:0; margin-top:.1rem; }
.drag-handle:hover { color:#6b7280; }
.sortable-ghost { background:#f0fdf4 !important; box-shadow:none !important; }
.pl-item-body { flex:1; min-width:0; }
.pl-item-top { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:.35rem; }
.pl-type-badge { font-size:.65rem; text-transform:uppercase; letter-spacing:.06em; background:#f3f4f6; color:#374151; padding:.15em .55em; border-radius:4px; font-weight:700; flex-shrink:0; }
.pl-title { font-weight:600; font-size:.9rem; cursor:pointer; border:1px solid transparent; border-radius:4px; padding:.1em .3em; background:transparent; min-width:60px; }
.pl-title:hover { border-color:#d1d5db; background:#f9fafb; }
.pl-title:focus { border-color:var(--morz-red); background:#fff; outline:none; box-shadow:0 0 0 2px rgba(227,6,19,.15); }
.pl-meta { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; font-size:.8rem; color:#6b7280; }
.pl-duration-wrap { display:inline-flex; align-items:center; gap:.25rem; }
.pl-duration { cursor:pointer; border:1px solid transparent; border-radius:4px; padding:.1em .3em; background:transparent; width:4rem; text-align:center; font-size:.8rem; color:#6b7280; }
.pl-duration:hover { border-color:#d1d5db; background:#f9fafb; }
.pl-duration:focus { border-color:var(--morz-red); background:#fff; outline:none; }
/* Enable toggle */
.pl-toggle { position:relative; display:inline-flex; align-items:center; cursor:pointer; gap:.4rem; }
.pl-toggle input { position:absolute; opacity:0; width:0; height:0; }
.pl-toggle-track { width:32px; height:18px; border-radius:9px; background:#d1d5db; transition:background .2s; flex-shrink:0; }
.pl-toggle input:checked + .pl-toggle-track { background:#22c55e; }
.pl-toggle-thumb { position:absolute; width:14px; height:14px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.2); transition:left .2s; left:2px; top:2px; }
.pl-toggle input:checked ~ .pl-toggle-thumb { left:16px; }
.pl-toggle-label { font-size:.75rem; color:#6b7280; }
/* Validity expand */
.pl-validity-toggle { font-size:.75rem; color:#6b7280; cursor:pointer; border:none; background:none; padding:0; text-decoration:underline dotted; }
.pl-validity-toggle:hover { color:var(--morz-red); }
.pl-validity-fields { margin-top:.5rem; display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; }
.pl-validity-fields .field { margin:0; }
.pl-validity-fields label { font-size:.7rem; color:#6b7280; display:block; margin-bottom:.2rem; }
/* Save indicator */
.save-ok { color:#22c55e; font-size:.8rem; opacity:0; transition:opacity .3s; }
.save-ok.show { opacity:1; }
/* Delete btn */
.pl-delete { position:absolute; top:.6rem; right:.6rem; background:none; border:none; cursor:pointer; color:#d1d5db; font-size:.9rem; padding:.15rem; border-radius:4px; opacity:0; transition:opacity .15s; }
.pl-item:hover .pl-delete { opacity:1; }
.pl-delete:hover { color:#ef4444; background:#fef2f2; }
/* Library */
.lib-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:.75rem; }
.lib-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); overflow:hidden; display:flex; flex-direction:column; }
.lib-thumb { width:100%; height:80px; object-fit:cover; background:#f3f4f6; display:flex; align-items:center; justify-content:center; font-size:2rem; }
.lib-info { padding:.5rem .6rem; flex:1; }
.lib-title { font-size:.75rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-badge { font-size:.6rem; text-transform:uppercase; color:#6b7280; }
.lib-actions { padding:.4rem .6rem; border-top:1px solid #f3f4f6; display:flex; gap:.35rem; }
/* Upload zone */
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
/* Screenshot */
.screen-preview { width:100%; max-height:200px; object-fit:cover; background:#1e293b; display:block; border-radius:var(--radius) var(--radius) 0 0; }
/* Modal */
.modal-card { border-radius:var(--radius); overflow:hidden; }
.modal-card-head { background:var(--nav-bg); }
.modal-card-title { color:#fff; font-weight:700; }
/* Toasts */
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
.morz-toast.show { transform:translateX(0); }
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
</style>
</head>
<body>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
{{if .IsAdmin}}<a class="navbar-item" href="{{.BackLink}}" style="font-size:.85rem">{{.BackLabel}}</a>{{end}}
<span class="navbar-item morz-brand"><span class="accent">MORZ</span>&nbsp;<span style="color:rgba(255,255,255,.85)">{{.Screen.Name}}</span></span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="manageNav">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</a>
</div>
<div id="manageNav" class="navbar-menu">
<div class="navbar-start">
{{if gt (len .AccessibleScreens) 1}}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">Bildschirm wechseln</a>
<div class="navbar-dropdown">
{{range .AccessibleScreens}}
<a class="navbar-item{{if eq .Slug $.Screen.Slug}} is-active{{end}}" href="/manage/{{.Slug}}">{{.Name}}</a>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Delete confirm modal -->
<div id="del-modal" class="modal">
<div class="modal-background" onclick="document.getElementById('del-modal').classList.remove('is-active')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title" id="del-title">Löschen?</p>
<button class="delete" onclick="document.getElementById('del-modal').classList.remove('is-active')"></button>
</header>
<section class="modal-card-body"><p id="del-body"></p></section>
<footer class="modal-card-foot" style="gap:.5rem">
<form id="del-form" method="POST"><button class="button is-danger" type="submit">Löschen</button></form>
<button class="button" onclick="document.getElementById('del-modal').classList.remove('is-active')">Abbrechen</button>
</footer>
</div>
</div>
<section class="section pt-4">
<div class="container">
<!-- Screenshot -->
<div class="box mb-4" style="padding:0;overflow:hidden">
<img class="screen-preview"
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
alt="Screenshot {{.Screen.Name}}">
<div style="padding:.75rem 1rem;display:flex;align-items:center;gap:.75rem;background:var(--surface)">
<span class="tag is-light" style="font-size:.75rem">{{orientationLabel .Screen.Orientation}}</span>
<span style="font-size:.8rem;color:#6b7280">{{.Screen.Slug}}</span>
</div>
</div>
<div class="manage-layout">
<!-- LEFT: Playlist -->
<div>
<div class="box">
<h2 class="title is-5 mb-4">Playlist</h2>
{{if .Items}}
<div id="sortable-items">
{{range .Items}}
<div class="pl-item{{if not .Enabled}} is-disabled{{end}}" id="item-{{.ID}}" data-id="{{.ID}}">
<span class="drag-handle" role="button" tabindex="0" title="Ziehen zum Sortieren">⠿</span>
<div class="pl-item-body">
<div class="pl-item-top">
<span class="pl-type-badge">{{typeIcon .Type}} {{.Type}}</span>
<input class="pl-title"
value="{{.Title}}"
placeholder="{{shortSrc .Src}}"
data-id="{{.ID}}"
onblur="saveField(this,'title')"
onkeydown="if(event.key==='Enter'){this.blur()}">
<span class="save-ok" id="ok-title-{{.ID}}">✓</span>
</div>
<div class="pl-meta">
<div class="pl-duration-wrap">
<input class="pl-duration" type="number" min="1" max="3600"
value="{{.DurationSeconds}}"
data-id="{{.ID}}"
onblur="saveField(this,'duration_seconds')"
onkeydown="if(event.key==='Enter'){this.blur()}"
title="Anzeigedauer in Sekunden">
<span>s</span>
<span class="save-ok" id="ok-dur-{{.ID}}">✓</span>
</div>
<label class="pl-toggle" title="Aktiv / Deaktiviert">
<input type="checkbox" {{if .Enabled}}checked{{end}} onchange="toggleEnabled('{{.ID}}',this.checked)">
<span class="pl-toggle-track"></span>
<span class="pl-toggle-thumb"></span>
<span class="pl-toggle-label" id="enabled-label-{{.ID}}">{{if .Enabled}}Aktiv{{else}}Aus{{end}}</span>
</label>
{{if or .ValidFrom .ValidUntil}}
<button class="pl-validity-toggle" type="button" onclick="toggleValidity('{{.ID}}')">
{{if and .ValidFrom .ValidUntil}}{{formatDateDE .ValidFrom}} {{formatDateDE .ValidUntil}}{{else if .ValidFrom}}ab {{formatDateDE .ValidFrom}}{{else}}bis {{formatDateDE .ValidUntil}}{{end}}
</button>
{{else}}
<button class="pl-validity-toggle" type="button" onclick="toggleValidity('{{.ID}}')">+ Gültigkeit</button>
{{end}}
</div>
<div class="pl-validity-fields" id="validity-{{.ID}}" style="display:none">
<div class="field">
<label>Gültig ab</label>
<input class="input is-small" type="datetime-local"
value="{{formatDT .ValidFrom}}"
data-id="{{.ID}}"
onblur="saveField(this,'valid_from')">
</div>
<div class="field">
<label>Gültig bis</label>
<input class="input is-small" type="datetime-local"
value="{{formatDT .ValidUntil}}"
data-id="{{.ID}}"
onblur="saveField(this,'valid_until')">
</div>
<span class="save-ok" id="ok-validity-{{.ID}}">✓</span>
</div>
</div>
<button class="pl-delete" type="button"
title="Aus Playlist entfernen"
onclick="openDelModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete','Eintrag entfernen?','Eintrag wirklich aus der Playlist entfernen?')">✕</button>
</div>
{{end}}
</div>
<p class="help has-text-grey mt-3" style="font-size:.75rem">Per Drag &amp; Drop sortieren oder Felder direkt bearbeiten.</p>
{{else}}
<div class="notification is-light">
Die Playlist ist leer. Füge Medien aus der Bibliothek hinzu.
</div>
{{end}}
</div>
</div>
<!-- RIGHT: Library + Upload -->
<div>
<!-- Upload (collapsed) -->
<div class="box mb-3">
<details id="upload-details">
<summary style="cursor:pointer;font-weight:700;font-size:.9rem;list-style:none;display:flex;align-items:center;justify-content:space-between">
<span>+ Medium hinzufügen</span>
<span style="font-size:.75rem;color:#9ca3af">▼</span>
</summary>
<div style="margin-top:1rem">
<div class="tabs is-small mb-3" id="upload-tabs">
<ul>
<li id="utab-file" class="is-active"><a onclick="switchUploadTab('file')" style="cursor:pointer">📁 Datei</a></li>
<li id="utab-web"><a onclick="switchUploadTab('web')" style="cursor:pointer">🌐 URL</a></li>
</ul>
</div>
<div id="upanel-file">
<form id="upload-form" method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<div class="field">
<label class="label is-small">Typ</label>
<div class="select is-small">
<select name="type" id="upload-type-sel" onchange="updateAccept(this.value)">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input is-small" type="text" name="title" placeholder="Aus Dateinamen abgeleitet">
</div>
<div class="field">
<label class="label is-small">Datei</label>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('upload-file-inp').click()" ondragover="event.preventDefault();this.classList.add('dragover')" ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
<p style="font-size:1.5rem">📂</p>
<p>Klicken oder Datei hierher ziehen</p>
<p id="drop-filename" style="display:none;color:#374151;font-weight:600"></p>
</div>
<input type="file" id="upload-file-inp" name="file" accept="image/*,video/*,application/pdf" style="display:none" onchange="updateDropLabel(this)">
</div>
<div id="upload-progress-wrap" style="display:none" class="mt-2">
<progress id="upload-progress" class="progress is-primary is-small" value="0" max="100">0%</progress>
</div>
<div id="upload-error" class="notification is-danger is-light is-small mt-2" style="display:none"></div>
<button class="button is-primary is-small mt-2" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button>
</form>
</div>
<div id="upanel-web" style="display:none">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<input type="hidden" name="type" value="web">
<div class="field">
<label class="label is-small">URL</label>
<input class="input is-small" type="url" name="url" placeholder="https://example.com" required>
</div>
<div class="field">
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input is-small" type="text" name="title" placeholder="Anzeigename">
</div>
<button class="button is-primary is-small" type="submit">Hinzufügen</button>
</form>
</div>
</div>
</details>
</div>
<!-- Library -->
<div class="box">
<h2 class="title is-6 mb-3">Medienbibliothek</h2>
{{if .Assets}}
<div class="lib-grid">
{{range .Assets}}
<div class="lib-card">
<div class="lib-thumb">
{{if eq .Type "image"}}<img src="{{if .StoragePath}}/uploads/{{.StoragePath}}{{else}}{{.OriginalURL}}{{end}}" style="width:100%;height:80px;object-fit:cover" alt="" loading="lazy" onerror="this.style.display='none';this.parentElement.textContent='🖼'">
{{else if eq .Type "video"}}🎬
{{else if eq .Type "pdf"}}📄
{{else}}🌐{{end}}
</div>
<div class="lib-info">
<div class="lib-title" title="{{.Title}}">{{.Title}}</div>
<div class="lib-badge">{{typeIcon .Type}} {{.Type}}</div>
</div>
<div class="lib-actions">
{{if index $.AddedAssets .ID}}
<span style="font-size:.7rem;color:#22c55e;font-weight:600">✓ In Playlist</span>
{{else}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="flex:1">
<input type="hidden" name="media_asset_id" value="{{.ID}}">
<button class="button is-primary is-small is-fullwidth" type="submit">+ Hinzufügen</button>
</form>
{{end}}
<button class="button is-small is-danger is-outlined" type="button"
title="Aus Bibliothek löschen"
onclick="openDelModal('/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete','Medium löschen?','Wirklich löschen? Playlist-Einträge bleiben bestehen.')">🗑</button>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="has-text-grey" style="font-size:.875rem">Noch keine Medien. Lade oben eine Datei hoch.</p>
{{end}}
</div>
</div>
</div><!-- /manage-layout -->
</div>
</section>
<script>
var SCREEN_SLUG = '{{.Screen.Slug}}';
var SERVER_TZ = '{{.ServerTimezone}}';
// ─── Navbar burger ───────────────────────────────────────────────
(function() {
var b = document.querySelector('.navbar-burger');
if (b) b.addEventListener('click', function() {
b.classList.toggle('is-active');
document.getElementById(b.dataset.target).classList.toggle('is-active');
});
})();
// ─── Delete modal ────────────────────────────────────────────────
function openDelModal(action, title, body) {
document.getElementById('del-form').action = action;
document.getElementById('del-title').textContent = title;
document.getElementById('del-body').textContent = body;
document.getElementById('del-modal').classList.add('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') document.getElementById('del-modal').classList.remove('is-active');
});
// ─── CSRF ────────────────────────────────────────────────────────
function getCsrf() { var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)'); return m ? decodeURIComponent(m[1]) : ''; }
function injectCSRF() {
var t = getCsrf(); if (!t) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) { var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i); }
});
}
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
// ─── Toast ───────────────────────────────────────────────────────
function showToast(msg, type) {
var t = document.createElement('div');
t.className = 'morz-toast ' + (type || 'is-success');
t.innerHTML = msg + '<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
document.body.appendChild(t);
requestAnimationFrame(function() { t.classList.add('show'); });
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
}
// ─── ?msg= toast ─────────────────────────────────────────────────
(function() {
var msg = new URLSearchParams(window.location.search).get('msg');
if (!msg) return;
var texts = { uploaded:'✓ Medium hochgeladen.', deleted:'✓ Gelöscht.', saved:'✓ Gespeichert.', added:'✓ Hinzugefügt.' };
showToast(texts[msg] || '✓ OK');
history.replaceState(null, '', window.location.pathname);
})();
// ─── Inline field save ───────────────────────────────────────────
function saveField(el, fieldName) {
var id = el.dataset.id;
var params = new URLSearchParams();
params.set('csrf_token', getCsrf());
// Always send all required fields by reading the item's current DOM state
var item = document.getElementById('item-' + id);
var titleEl = item.querySelector('.pl-title');
var durEl = item.querySelector('.pl-duration');
var togEl = item.querySelector('.pl-toggle input');
var vfEl = item.querySelector('[onblur*="valid_from"]');
var vuEl = item.querySelector('[onblur*="valid_until"]');
params.set('title', titleEl ? titleEl.value : '');
params.set('duration_seconds', durEl ? durEl.value : '20');
params.set('enabled', togEl && togEl.checked ? 'true' : 'false');
if (vfEl && vfEl.value) params.set('valid_from', vfEl.value);
if (vuEl && vuEl.value) params.set('valid_until', vuEl.value);
fetch('/manage/' + SCREEN_SLUG + '/items/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'fetch' },
body: params.toString()
}).then(function(r) {
if (r.ok) {
var okId = fieldName === 'duration_seconds' ? 'ok-dur-' : fieldName.startsWith('valid') ? 'ok-validity-' : 'ok-title-';
var okEl = document.getElementById(okId + id);
if (okEl) { okEl.classList.add('show'); setTimeout(function() { okEl.classList.remove('show'); }, 1500); }
} else {
showToast('⚠ Speichern fehlgeschlagen.', 'is-danger');
}
}).catch(function() { showToast('⚠ Netzwerkfehler.', 'is-danger'); });
}
// ─── Toggle enabled ──────────────────────────────────────────────
function toggleEnabled(id, checked) {
var item = document.getElementById('item-' + id);
if (checked) item.classList.remove('is-disabled'); else item.classList.add('is-disabled');
var label = document.getElementById('enabled-label-' + id);
if (label) label.textContent = checked ? 'Aktiv' : 'Aus';
// Save via saveField using the title input as trigger element proxy
var titleEl = item.querySelector('.pl-title');
if (titleEl) saveField(titleEl, 'enabled');
}
// ─── Toggle validity section ─────────────────────────────────────
function toggleValidity(id) {
var el = document.getElementById('validity-' + id);
if (!el) return;
el.style.display = (el.style.display === 'none' || !el.style.display) ? 'flex' : 'none';
}
// ─── Sortable drag-and-drop ──────────────────────────────────────
var sortableEl = document.getElementById('sortable-items');
if (sortableEl) {
Sortable.create(sortableEl, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function() {
var ids = Array.from(sortableEl.querySelectorAll('.pl-item[data-id]')).map(function(el) { return el.dataset.id; });
fetch('/manage/' + SCREEN_SLUG + '/reorder', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(ids)
}).then(function(r) {
if (!r.ok) { showToast('⚠ Reihenfolge nicht gespeichert.','is-danger'); window.location.reload(); }
}).catch(function() { showToast('⚠ Netzwerkfehler.','is-danger'); window.location.reload(); });
}
});
}
// ─── Upload tab switch ───────────────────────────────────────────
function switchUploadTab(name) {
['file','web'].forEach(function(t) {
document.getElementById('utab-' + t).classList.toggle('is-active', t === name);
document.getElementById('upanel-' + t).style.display = t === name ? '' : 'none';
});
}
function updateAccept(type) {
var inp = document.getElementById('upload-file-inp');
if (!inp) return;
inp.accept = type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : 'application/pdf';
}
function updateDropLabel(inp) {
var p = document.getElementById('drop-filename');
if (!p) return;
if (inp.files && inp.files[0]) { p.textContent = inp.files[0].name; p.style.display=''; }
}
function handleDrop(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.remove('dragover');
var inp = document.getElementById('upload-file-inp');
if (inp && e.dataTransfer.files.length) { inp.files = e.dataTransfer.files; updateDropLabel(inp); }
}
function startUpload() {
var form = document.getElementById('upload-form');
var inp = document.getElementById('upload-file-inp');
var btn = document.getElementById('upload-btn');
var pg = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var err = document.getElementById('upload-error');
err.style.display = 'none';
if (!inp.files || !inp.files.length) { err.textContent='Bitte zuerst eine Datei auswählen.';err.style.display='';return; }
var fd = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) { if (e.lengthComputable) { bar.value=Math.round(e.loaded/e.total*100); } };
xhr.onloadstart = function() { btn.style.display='none'; pg.style.display=''; bar.value=0; };
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 400) { window.location.href='/manage/'+SCREEN_SLUG+'?msg=uploaded'; }
else { pg.style.display='none'; btn.style.display=''; err.textContent='Upload fehlgeschlagen: '+xhr.responseText; err.style.display=''; }
};
xhr.onerror = function() { pg.style.display='none'; btn.style.display=''; err.textContent='Netzwerkfehler.'; err.style.display=''; };
xhr.open('POST', form.action);
xhr.send(fd);
}
// ─── Screenshot lazy-load ────────────────────────────────────────
(function() {
document.querySelectorAll('.screen-preview').forEach(function(img) {
img.src = img.dataset.src;
setTimeout(function() { img.src = img.dataset.src + '?t=' + Date.now(); }, 4000);
});
})();
</script>
</body>
</html>`
```
- [ ] **Step 2: Build & commit**
```bash
cd server/backend && go build ./... && git add server/backend/internal/httpapi/manage/templates.go && git commit -m "feat(ui): Playlist-Editor neu gestaltet (Karten, Inline-Edit, Zwei-Spalten)"
```
---
## Task 6: Screen overview (`screenOverviewTmpl`)
**Files:**
- Modify: `server/backend/internal/httpapi/manage/templates.go` (replace `screenOverviewTmpl` const, lines 15041567)
- [ ] **Step 1: Replace `screenOverviewTmpl`**
```go
const screenOverviewTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meine Bildschirme MORZ Infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; min-height:100vh; }
.navbar { background:var(--nav-bg) !important; }
.navbar-item { color:rgba(255,255,255,.85) !important; }
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
.screen-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); overflow:hidden; display:flex; flex-direction:column; text-decoration:none; color:inherit; transition:box-shadow .15s, transform .15s; }
.screen-card:hover { box-shadow:var(--shadow-md); transform:translateY(-2px); }
.screen-thumb-wrap { position:relative; }
.screen-thumb { width:100%; height:180px; object-fit:cover; background:#1e293b; display:block; }
.screen-status-dot { position:absolute; top:.65rem; right:.65rem; width:12px; height:12px; border-radius:50%; border:2px solid rgba(255,255,255,.8); background:#9ca3af; }
.screen-status-dot.online { background:#22c55e; }
.screen-status-dot.stale { background:#f59e0b; }
.screen-status-dot.offline { background:#ef4444; }
.screen-card-body { padding:1rem; }
.screen-card-name { font-weight:700; font-size:1rem; margin-bottom:.25rem; display:flex; align-items:center; gap:.4rem; }
.screen-card-sub { font-size:.8rem; color:#6b7280; margin-bottom:.85rem; }
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
.morz-toast.show { transform:translateX(0); }
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
</style>
</head>
<body>
<nav class="navbar" role="navigation">
<div class="navbar-brand">
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
<div class="columns is-multiline">
{{range .Cards}}
<div class="column is-one-third-desktop is-half-tablet">
<div class="screen-card">
<div class="screen-thumb-wrap">
<img class="screen-thumb" data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot" alt="{{.Screen.Name}}">
<span class="screen-status-dot unknown" id="status-{{.Screen.Slug}}" title="Unbekannt"></span>
</div>
<div class="screen-card-body">
<div class="screen-card-name">
{{if eq .Screen.Orientation "portrait"}}📱{{else}}🖥{{end}}
{{.Screen.Name}}
</div>
<div class="screen-card-sub">{{orientationLabel .Screen.Orientation}} · {{.Screen.Slug}}</div>
<a class="button is-primary is-fullwidth" href="/manage/{{.Screen.Slug}}">Verwalten →</a>
</div>
</div>
</div>
{{end}}
</div>
</div>
</section>
<script>
(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src;
setTimeout(function() { img.src = img.dataset.src + '?t=' + Date.now(); }, 4000);
});
})();
(function() {
function update() {
fetch('/api/v1/screens/status').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
if (!data || !data.screens) return;
data.screens.forEach(function(s) {
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var st = s.derived_state || 'unknown';
el.className = 'screen-status-dot ' + (st === 'online' ? 'online' : st === 'degraded' ? 'stale' : 'offline');
el.title = st;
});
}).catch(function(){});
}
update(); setInterval(update, 30000);
})();
function getCsrf() { var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)'); return m ? decodeURIComponent(m[1]) : ''; }
function injectCSRF() {
var t = getCsrf(); if (!t) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) { var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i); }
});
}
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
</script>
</body>
</html>`
```
- [ ] **Step 2: Build & commit**
```bash
cd server/backend && go build ./... && git add server/backend/internal/httpapi/manage/templates.go && git commit -m "feat(ui): Screen-Übersicht neu gestaltet"
```
---
## Task 7: Tenant dashboard (`tenantDashTmpl`)
**Files:**
- Modify: `server/backend/internal/httpapi/tenant/templates.go` (replace `tenantDashTmpl` const)
- [ ] **Step 1: Replace `tenantDashTmpl`**
```go
const tenantDashTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard {{.Tenant.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; min-height:100vh; }
.navbar { background:var(--nav-bg) !important; }
.navbar-item { color:rgba(255,255,255,.85) !important; }
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
.button { border-radius:var(--radius-btn) !important; }
.input:focus,.select select:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.12); outline:none; }
/* Tabs */
.tabs li.is-active a { border-bottom:3px solid var(--morz-red) !important; color:var(--morz-red) !important; font-weight:600; }
.tabs a { border-bottom:3px solid transparent; color:#374151; }
.tab-content { display:none; }
.tab-content.is-active { display:block; }
/* Screen cards */
.sc-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:1.25rem; display:flex; flex-direction:column; gap:.75rem; transition:box-shadow .15s; }
.sc-card:hover { box-shadow:var(--shadow-md); }
.sc-name { font-weight:700; font-size:1.05rem; display:flex; align-items:center; gap:.5rem; }
.sc-sub { font-size:.8rem; color:#6b7280; display:flex; align-items:center; gap:.5rem; }
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; flex-shrink:0; }
.status-dot.online { background:#22c55e; }
.status-dot.stale { background:#f59e0b; }
.status-dot.offline{ background:#ef4444; }
.status-dot.unknown{ background:#9ca3af; }
/* Upload zone */
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
/* Toast */
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
.morz-toast.show { transform:translateX(0); }
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
</style>
</head>
<body>
<nav class="navbar" role="navigation">
<div class="navbar-brand">
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
{{if .Flash}}
<div class="notification is-success is-light mb-4" role="alert">
<button class="delete" onclick="this.parentElement.remove()"></button>{{.Flash}}
</div>
{{end}}
<div class="tabs mb-0" id="dash-tabs">
<ul>
<li class="is-active" data-tab="monitors"><a>Meine Monitore</a></li>
<li data-tab="media"><a>Mediathek</a></li>
</ul>
</div>
<!-- Tab: Monitors -->
<div id="tab-monitors" class="tab-content is-active">
<div class="box" style="border-radius:0 var(--radius) var(--radius) var(--radius)">
{{if .Screens}}
<div class="columns is-multiline">
{{range .Screens}}
<div class="column is-4-desktop is-6-tablet">
<div class="sc-card">
<div class="sc-name">
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
{{.Name}}
</div>
<div class="sc-sub">
<span class="status-dot unknown" id="status-{{.Slug}}" title="Unbekannt"></span>
<span>{{orientationLabel .Orientation}}</span>
</div>
<a class="button is-primary is-fullwidth" href="/manage/{{.Slug}}?from=tenant">Playlist bearbeiten →</a>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="has-text-grey">Noch keine Monitore zugewiesen.</p>
{{end}}
</div>
</div>
<!-- Tab: Media -->
<div id="tab-media" class="tab-content">
<div class="box" style="border-radius:0 var(--radius) var(--radius) var(--radius)">
<h2 class="title is-5 mb-3">Medium hochladen</h2>
<form id="upload-form" method="POST" action="/tenant/{{.Tenant.Slug}}/upload" enctype="multipart/form-data">
<div class="field">
<label class="label is-small">Typ</label>
<div class="select is-small">
<select name="type" id="upload-type" onchange="toggleTypeFields()">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
<option value="web">🌐 Website (URL)</option>
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input is-small" type="text" name="title" placeholder="Aus Dateinamen abgeleitet">
</div>
<div id="file-field" class="field">
<label class="label is-small">Datei</label>
<div class="upload-zone" onclick="document.getElementById('upload-file').click()" ondragover="event.preventDefault();this.classList.add('dragover')" ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
<p style="font-size:1.5rem">📂</p>
<p>Klicken oder Datei hierher ziehen</p>
<p id="drop-fn" style="display:none;color:#374151;font-weight:600"></p>
</div>
<input type="file" id="upload-file" name="file" accept="image/*,video/*,application/pdf" style="display:none" onchange="var p=document.getElementById('drop-fn');if(this.files[0]){p.textContent=this.files[0].name;p.style.display=''}">
</div>
<div id="url-field" class="field" style="display:none">
<label class="label is-small">URL</label>
<input class="input is-small" type="url" name="url" placeholder="https://...">
</div>
<div id="upload-progress-wrap" class="field" style="display:none">
<progress id="upload-progress" class="progress is-primary is-small" value="0" max="100">0%</progress>
</div>
<div id="upload-error" class="notification is-danger is-light is-small mt-2" style="display:none"></div>
<div class="field"><button class="button is-primary is-small" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button></div>
</form>
<hr>
<h2 class="title is-5 mb-3">Vorhandene Medien</h2>
{{if .Assets}}
<div style="overflow-x:auto">
<table class="table is-fullwidth is-hoverable">
<thead><tr style="font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280">
<th>Typ</th><th>Titel</th><th>Größe</th><th></th>
</tr></thead>
<tbody>
{{range .Assets}}
<tr>
<td>{{typeIcon .Type}}</td>
<td>{{.Title}}</td>
<td class="has-text-grey" style="font-size:.8rem">{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}{{end}}</td>
<td>
<form method="POST" action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
onsubmit="return confirm('Medium löschen?')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen.</p>
{{end}}
</div>
</div>
</div>
</section>
<script>
// ─── Tab switching ─────────────────────────────────────────────
(function() {
var tabs = document.querySelectorAll('#dash-tabs li[data-tab]');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('is-active'); });
tab.classList.add('is-active');
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('is-active'); });
document.getElementById('tab-' + tab.dataset.tab).classList.add('is-active');
history.replaceState(null, '', '?tab=' + tab.dataset.tab);
});
});
var tp = new URLSearchParams(window.location.search).get('tab');
if (tp) { var t = document.querySelector('#dash-tabs li[data-tab="' + tp + '"]'); if (t) t.click(); }
})();
// ─── Upload type toggle ────────────────────────────────────────
function toggleTypeFields() {
var t = document.getElementById('upload-type').value;
document.getElementById('file-field').style.display = t === 'web' ? 'none' : '';
document.getElementById('url-field').style.display = t === 'web' ? '' : 'none';
}
function handleDrop(e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var inp = document.getElementById('upload-file');
if (inp && e.dataTransfer.files.length) {
inp.files = e.dataTransfer.files;
var p = document.getElementById('drop-fn');
p.textContent = e.dataTransfer.files[0].name; p.style.display = '';
}
}
function startUpload() {
var t = document.getElementById('upload-type').value;
if (t === 'web') { document.getElementById('upload-form').submit(); return; }
var form = document.getElementById('upload-form');
var inp = document.getElementById('upload-file');
var btn = document.getElementById('upload-btn');
var pg = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var err = document.getElementById('upload-error');
err.style.display='none';
if (!inp.files || !inp.files.length) { err.textContent='Bitte eine Datei auswählen.';err.style.display='';return; }
var fd = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) { if (e.lengthComputable) bar.value=Math.round(e.loaded/e.total*100); };
xhr.onloadstart = function() { btn.style.display='none'; pg.style.display=''; bar.value=0; };
xhr.onload = function() {
if (xhr.status>=200&&xhr.status<400) { window.location.href=window.location.pathname+'?tab=media&flash=uploaded'; }
else { pg.style.display='none';btn.style.display='';err.textContent='Upload fehlgeschlagen: '+xhr.responseText;err.style.display=''; }
};
xhr.onerror = function() { pg.style.display='none';btn.style.display='';err.textContent='Netzwerkfehler.';err.style.display=''; };
xhr.open('POST', form.action); xhr.send(fd);
}
// ─── Status polling ────────────────────────────────────────────
(function() {
function update() {
fetch('/api/v1/screens/status').then(function(r){return r.ok?r.json():null;}).then(function(data){
if (!data||!data.screens) return;
data.screens.forEach(function(s){
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var st = s.derived_state || 'unknown';
el.className = 'status-dot ' + (st==='online'?'online':st==='degraded'?'stale':'offline');
el.title = st;
});
}).catch(function(){});
}
update(); setInterval(update, 30000);
})();
// ─── CSRF injection ────────────────────────────────────────────
function getCsrf() { var m=document.cookie.match('(?:^|; )morz_csrf=([^;]*)');return m?decodeURIComponent(m[1]):''; }
function injectCSRF() {
var t=getCsrf();if(!t)return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f){
if(!f.querySelector('input[name="csrf_token"]')){var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i);}
});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',injectCSRF);else injectCSRF();
// ─── Toast ─────────────────────────────────────────────────────
function showToast(msg,type){
var t=document.createElement('div');t.className='morz-toast '+(type||'is-success');
t.innerHTML=msg+'<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
document.body.appendChild(t);requestAnimationFrame(function(){t.classList.add('show');});
setTimeout(function(){t.classList.remove('show');setTimeout(function(){t.remove();},300);},3500);
}
(function(){
var flash = new URLSearchParams(window.location.search).get('flash');
if (flash==='uploaded') showToast('✓ Medium erfolgreich hochgeladen.');
})();
</script>
</body>
</html>`
```
- [ ] **Step 2: Build & commit**
```bash
cd server/backend && go build ./... && git add server/backend/internal/httpapi/tenant/templates.go && git commit -m "feat(ui): Tenant-Dashboard neu gestaltet"
```
---
## Task 8: Final build verification & integration test run
**Files:** none (verification only)
- [ ] **Step 1: Full build**
```bash
cd server/backend && go build ./...
```
Expected: no errors.
- [ ] **Step 2: Run tests**
```bash
cd server/backend && go test ./...
```
Expected: all tests pass. If any test fails, investigate — do not skip.
- [ ] **Step 3: Commit if tests required any fixes**
Only if Step 2 required code changes:
```bash
git add -p && git commit -m "fix(ui): Test-Fixes nach Frontend-Overhaul"
```