morz-infoboard/server/backend/internal/httpapi/manage/templates.go
Jesko Anschütz 68fc0bf4cf feat(ui): Display-Steuerbox in Playlist-Verwaltung
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 07:10:23 +01:00

1386 lines
70 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">
<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; }
/* Display control box */
.display-ctrl { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
.display-state-badge { font-size:.75rem; padding:.2em .65em; border-radius:99px; font-weight:700; }
.display-state-badge.on { background:#dcfce7; color:#166534; }
.display-state-badge.off { background:#fee2e2; color:#991b1b; }
.display-state-badge.unknown { background:#f3f4f6; color:#6b7280; }
</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>
<!-- Display control -->
<div class="box mb-3">
<h3 class="title is-6 mb-3">Display</h3>
<div class="display-ctrl">
<span id="display-state-badge" class="display-state-badge {{.DisplayState}}">
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}Unbekannt{{end}}
</span>
<button class="button is-small is-success is-light" type="button"
onclick="sendDisplayCmd('on')">Einschalten</button>
<button class="button is-small is-danger is-light" type="button"
onclick="sendDisplayCmd('off')">Ausschalten</button>
</div>
</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);
}
// ─── Display control ─────────────────────────────────────────────
function sendDisplayCmd(state) {
var slug = {{.Screen.Slug | printf "%q"}};
fetch('/api/v1/screens/' + slug + '/display', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
'X-Requested-With': 'fetch'
},
body: JSON.stringify({state: state})
}).then(function(r) {
if (r.ok) {
var badge = document.getElementById('display-state-badge');
if (badge) {
badge.className = 'display-state-badge ' + state;
badge.textContent = state === 'on' ? 'An' : 'Aus';
}
showToast('Display ' + (state === 'on' ? 'eingeschaltet' : 'ausgeschaltet'), 'is-success');
} else {
showToast('Fehler beim Schalten', 'is-danger');
}
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
}
// ─── ?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',
'X-CSRF-Token': getCsrf(),
'X-Requested-With': 'fetch'
},
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>`
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>`