morz-infoboard/server/backend/internal/httpapi/manage/templates.go
Alwin a691186d9a feat(ui): Admin-Dashboard neu gestaltet (Karten-Grid, Tabs, Modals)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:54:42 +00:00

1381 lines
60 KiB
Go
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.

package manage
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>`
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>`
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>`
const manageTmpl = `<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<script src="/static/Sortable.min.js"></script>
<style>
body { background: #f5f5f5; }
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
.drag-handle:hover { color: #333; }
.item-disabled td { opacity: 0.5; }
.edit-row td { background: var(--bulma-warning-light, hsl(48, 100%, 96%)); padding: 0.75rem 1rem; }
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
.sortable-ghost { background: var(--bulma-info-light, hsl(207, 61%, 94%)) !important; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
{{if .IsAdmin}}<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>{{end}}
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="manageNavbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="manageNavbar" 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-white is-outlined is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Lösch-Bestätigungs-Modal -->
<div id="manage-delete-modal" class="modal">
<div class="modal-background" onclick="closeManageDeleteModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title" id="manage-delete-modal-title">Eintrag entfernen?</p>
<button class="delete" aria-label="Schließen" onclick="closeManageDeleteModal()"></button>
</header>
<section class="modal-card-body">
<p id="manage-delete-modal-body">Soll der Eintrag wirklich entfernt werden?</p>
</section>
<footer class="modal-card-foot">
<form id="manage-delete-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeManageDeleteModal()">Abbrechen</button>
</footer>
</div>
</div>
<script>
(function() {
var msg = new URLSearchParams(window.location.search).get('msg');
if (!msg) return;
var texts = {
'uploaded': '✓ Medium erfolgreich hochgeladen.',
'deleted': '✓ Erfolgreich gelöscht.',
'saved': '✓ Änderungen gespeichert.',
'added': '✓ Erfolgreich hinzugefügt.'
};
var text = texts[msg] || '✓ Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification is-success';
n.setAttribute('role', 'alert');
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + text;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
document.body.appendChild(n);
setTimeout(function() {
n.style.transition = 'opacity .5s';
n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500);
}, 3000);
// Clean URL without reloading
var url = new URL(window.location.href);
url.searchParams.delete('msg');
history.replaceState(null, '', url.toString());
})();
function openManageDeleteModal(action, title, body) {
document.getElementById('manage-delete-modal-form').action = action;
document.getElementById('manage-delete-modal-title').textContent = title;
document.getElementById('manage-delete-modal-body').textContent = body;
document.getElementById('manage-delete-modal').classList.add('is-active');
}
function closeManageDeleteModal() {
document.getElementById('manage-delete-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeManageDeleteModal();
});
</script>
<section class="section pb-0 pt-3">
<div class="container">
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
<li class="is-active"><a href="#" aria-current="page">{{.Screen.Name}}</a></li>
</ul>
</nav>
</div>
</section>
<section class="section pt-2">
<div class="container">
<!-- ── Screenshot ── -->
<div class="box" style="padding:0;overflow:hidden;margin-bottom:1.5rem">
<img class="screen-thumb"
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
style="width:100%;max-height:220px;object-fit:cover;background:#222;display:block"
alt="Screenshot {{.Screen.Name}}">
</div>
<!-- ── Playlist ── -->
<div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}}
<div style="overflow-x: auto">
<table class="table is-fullwidth" id="playlist-table">
<thead>
<tr>
<th style="width:2rem"></th>
<th style="width:5rem">Typ</th>
<th>Titel / Quelle</th>
<th style="width:6rem">Dauer</th>
<th style="width:7rem">Status</th>
<th style="width:12rem">Aktionen</th>
</tr>
</thead>
<tbody id="sortable-items">
{{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
<td style="white-space:nowrap">
<span class="drag-handle" role="button" aria-label="Reihenfolge per Drag ändern" tabindex="0" title="Ziehen zum Sortieren">⠿</span>
<button type="button" class="button is-small is-white px-1" style="min-width:1.6rem" title="Nach oben" aria-label="Eintrag nach oben" onclick="reorderMove('{{.ID}}', -1)">▲</button>
<button type="button" class="button is-small is-white px-1" style="min-width:1.6rem" title="Nach unten" aria-label="Eintrag nach unten" onclick="reorderMove('{{.ID}}', 1)">▼</button>
</td>
<td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td>
<td>
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
{{if and .ValidFrom .ValidUntil}}<span class="tag is-info is-light is-small mt-1">{{formatDateDE .ValidFrom}} {{formatDateDE .ValidUntil}}</span>
{{else if .ValidFrom}}<span class="tag is-info is-light is-small mt-1">ab {{formatDateDE .ValidFrom}}</span>
{{else if .ValidUntil}}<span class="tag is-info is-light is-small mt-1">bis {{formatDateDE .ValidUntil}}</span>{{end}}
</td>
<td>{{.DurationSeconds}}&thinsp;s</td>
<td>
{{if .Enabled}}
<span class="tag is-success is-light">Aktiv</span>
{{else}}
<span class="tag is-warning is-light">Deaktiviert</span>
{{end}}
</td>
<td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<button class="button is-small is-danger is-outlined"
type="button"
aria-label="{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} aus Playlist entfernen"
title="Entfernen"
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete', 'Eintrag entfernen?', 'Eintrag wirklich aus der Playlist entfernen?')">✕</button>
</td>
</tr>
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
<td colspan="6">
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}">
<div class="columns is-vcentered is-multiline">
<div class="column is-4">
<label class="label is-small">Titel</label>
<input class="input is-small" type="text" name="title" value="{{.Title}}"
placeholder="Anzeigename (optional)">
</div>
<div class="column is-narrow">
<label class="label is-small">Dauer (Sek.)</label>
<input class="input is-small" type="number" name="duration_seconds"
value="{{.DurationSeconds}}" min="1" max="3600" style="width:6rem">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig ab</label>
<input class="input is-small" type="datetime-local" name="valid_from"
value="{{formatDT .ValidFrom}}">
<p class="help">Zeiten in Zeitzone des Servers (Europe/Berlin)</p>
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig bis</label>
<input class="input is-small" type="datetime-local" name="valid_until"
value="{{formatDT .ValidUntil}}">
<p class="help">Zeiten in Zeitzone des Servers (Europe/Berlin)</p>
</div>
<div class="column is-narrow">
<label class="label is-small">Aktiv</label>
<div class="control" style="padding-top:0.4rem">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{if .Enabled}}checked{{end}}>
Aktiv
</label>
</div>
</div>
<div class="column is-narrow">
<label class="label is-small">&nbsp;</label>
<div class="buttons">
<button class="button is-small is-success" type="submit">Speichern</button>
<button class="button is-small" type="button" onclick="toggleEdit('{{.ID}}')">Abbrechen</button>
</div>
</div>
</div>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}}
<div class="notification is-light">
Die Playlist ist noch leer. Füge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
</div>
{{end}}
</div>
<!-- ── Medienbibliothek ── -->
<div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th style="width:5rem">Typ</th>
<th>Titel</th>
<th>Quelle</th>
<th style="width:14rem">Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td><span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span></td>
<td>{{.Title}}</td>
<td>
<small class="has-text-grey">
{{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
</small>
</td>
<td>
{{if index $.AddedAssets .ID}}
<span class="tag is-success is-light mr-2">✓ In Playlist</span>
{{else}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="display:inline">
<input type="hidden" name="media_asset_id" value="{{.ID}}">
<button class="button is-small is-primary" type="submit">+ Hinzufügen</button>
</form>
&nbsp;
{{end}}
<button class="button is-small is-danger is-outlined"
type="button"
aria-label="{{.Title}} aus Bibliothek löschen"
title="Aus Bibliothek löschen"
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete', 'Medium löschen?', 'Medium wirklich aus der Bibliothek löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">🗑</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="notification is-light">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</div>
{{end}}
</div>
<!-- ── Neues Medium hinzufügen ── -->
<div class="box">
<h2 class="title is-5 mb-3">Neues Medium hinzufügen</h2>
<div class="tabs" id="upload-tabs">
<ul>
<li id="tab-file" class="is-active"><a><button type="button" role="tab" aria-selected="true" onclick="switchTab('file')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">📁 Datei hochladen</button></a></li>
<li id="tab-web"><a><button type="button" role="tab" aria-selected="false" onclick="switchTab('web')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">🌐 Webseite / URL</button></a></li>
</ul>
</div>
<div id="panel-file" class="tab-panel is-active">
<form id="upload-form" method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<div class="columns is-vcentered">
<div class="column is-2">
<div class="field">
<label class="label">Typ</label>
<div class="select is-fullwidth">
<select name="type" id="upload-type-select" onchange="updateFileAccept(this.value)">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input" type="text" name="title"
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Datei</label>
<div class="control">
<input class="input" type="file" name="file" id="upload-file-input" required
accept="image/*,video/*,application/pdf">
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button>
</div>
</div>
</div>
<div id="upload-progress-wrap" style="display:none" class="mt-2">
<progress id="upload-progress" class="progress is-primary" value="0" max="100">0%</progress>
</div>
<div id="upload-error" class="notification is-danger is-light mt-2" style="display:none"></div>
</form>
</div>
<div id="panel-web" class="tab-panel">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<input type="hidden" name="type" value="web">
<div class="columns is-vcentered">
<div class="column">
<div class="field">
<label class="label">URL</label>
<input class="input" type="url" name="url"
placeholder="https://example.com" required>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input" type="text" name="title" placeholder="Anzeigename">
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hinzufügen</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
<script>
// Navbar burger toggle
(function() {
var burger = document.querySelector('.navbar-burger[data-target="manageNavbar"]');
if (burger) {
burger.addEventListener('click', function() {
var target = document.getElementById(burger.dataset.target);
burger.classList.toggle('is-active');
target.classList.toggle('is-active');
});
}
})();
function toggleEdit(id) {
var row = document.getElementById('edit-' + id);
if (row) {
var isHidden = (row.style.display === 'none' || row.style.display === '');
row.style.display = isHidden ? 'table-row' : 'none';
if (isHidden) {
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
function switchTab(tab) {
var panels = ['file', 'web'];
panels.forEach(function(p) {
var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p);
if (!panel || !tabEl) return;
var btn = tabEl.querySelector('[role="tab"]');
if (p === tab) {
panel.classList.add('is-active');
tabEl.classList.add('is-active');
if (btn) btn.setAttribute('aria-selected', 'true');
} else {
panel.classList.remove('is-active');
tabEl.classList.remove('is-active');
if (btn) btn.setAttribute('aria-selected', 'false');
}
});
}
// N3: Keyboard-Reorder per ▲/▼-Buttons
function reorderMove(itemId, direction) {
var tbody = document.getElementById('sortable-items');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr[id^="item-"]'));
var idx = rows.findIndex(function(r) { return r.id === 'item-' + itemId; });
if (idx < 0) return;
var newIdx = idx + direction;
if (newIdx < 0 || newIdx >= rows.length) return;
// DOM tauschen (auch die zugehörige edit-row mitnehmen)
var itemRow = document.getElementById('item-' + itemId);
var editRow = document.getElementById('edit-' + itemId);
var targetItemRow = rows[newIdx];
var targetEditRow = document.getElementById('edit-' + targetItemRow.id.replace('item-', ''));
if (direction < 0) {
tbody.insertBefore(itemRow, targetItemRow);
if (editRow) tbody.insertBefore(editRow, targetItemRow);
if (targetEditRow) tbody.insertBefore(targetItemRow, editRow || itemRow.nextSibling);
if (targetEditRow) tbody.insertBefore(targetEditRow, editRow || itemRow.nextSibling);
} else {
var after = (targetEditRow || targetItemRow).nextSibling;
tbody.insertBefore(itemRow, after);
if (editRow) tbody.insertBefore(editRow, after);
}
// Neue Reihenfolge ans Backend schicken
var ids = Array.from(tbody.querySelectorAll('tr[id^="item-"]')).map(function(r) {
return r.id.replace('item-', '');
});
fetch('/manage/{{.Screen.Slug}}/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
}).catch(function() {
showManageError('Reihenfolge konnte nicht gespeichert werden.');
});
}
// M10: Datei-Accept-Attribut dynamisch anpassen
function updateFileAccept(type) {
var inp = document.getElementById('upload-file-input');
if (!inp) return;
var acceptMap = { 'image': 'image/*', 'video': 'video/*', 'pdf': 'application/pdf' };
inp.accept = acceptMap[type] || 'image/*,video/*,application/pdf';
}
function showManageError(msg) {
var n = document.createElement('div');
n.className = 'notification is-danger';
n.setAttribute('role', 'alert');
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + msg;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
document.body.appendChild(n);
setTimeout(function() {
n.style.transition = 'opacity .5s';
n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500);
}, 4000);
}
// Drag-and-drop reordering
var sortableEl = document.getElementById('sortable-items');
if (sortableEl) {
Sortable.create(sortableEl, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function() {
var ids = [];
sortableEl.querySelectorAll('tr[id^="item-"]').forEach(function(tr) {
ids.push(tr.id.replace('item-', ''));
});
fetch('/manage/{{.Screen.Slug}}/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
}).then(function(response) {
if (!response.ok) {
showManageError('Reihenfolge konnte nicht gespeichert werden (HTTP ' + response.status + '). Seite wird neu geladen.');
window.location.reload();
}
}).catch(function() {
showManageError('Netzwerkfehler beim Speichern der Reihenfolge. Seite wird neu geladen.');
window.location.reload();
});
}
});
}
// XHR-Upload mit Fortschrittsbalken
function startUpload() {
var form = document.getElementById('upload-form');
var fileInput = document.getElementById('upload-file-input');
var btn = document.getElementById('upload-btn');
var progressWrap = document.getElementById('upload-progress-wrap');
var progress = document.getElementById('upload-progress');
var errorBox = document.getElementById('upload-error');
errorBox.style.display = 'none';
if (!fileInput.files || fileInput.files.length === 0) {
errorBox.textContent = 'Bitte zuerst eine Datei auswählen.';
errorBox.style.display = '';
return;
}
var formData = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
progress.value = pct;
progress.textContent = pct + '%';
}
};
xhr.onloadstart = function() {
btn.style.display = 'none';
progressWrap.style.display = '';
progress.value = 0;
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 400) {
window.location.href = '/manage/{{.Screen.Slug}}?msg=uploaded';
} else {
progressWrap.style.display = 'none';
btn.style.display = '';
errorBox.textContent = 'Upload fehlgeschlagen (HTTP ' + xhr.status + '): ' + xhr.responseText;
errorBox.style.display = '';
}
};
xhr.onerror = function() {
progressWrap.style.display = 'none';
btn.style.display = '';
errorBox.textContent = 'Netzwerkfehler beim Upload. Bitte erneut versuchen.';
errorBox.style.display = '';
};
xhr.open('POST', form.action);
xhr.send(formData);
}
</script>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
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();
}
})();
</script>
<script>
(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src;
});
setTimeout(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src + '?t=' + Date.now();
});
}, 4000);
})();
</script>
</body>
</html>`
const screenOverviewTmpl = `<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Bildschirme morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>morz infoboard</strong></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-white is-outlined is-small" 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="box" style="padding:0;overflow:hidden">
<img class="screen-thumb"
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
alt="{{.Screen.Name}}"
style="width:100%;height:180px;object-fit:cover;background:#222;display:block">
<div style="padding:1rem">
<p class="title is-5 mb-3">{{.Screen.Name}}</p>
<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() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src + '?t=' + Date.now();
});
}, 4000);
})();
</script>
</body>
</html>`