morz-infoboard/server/backend/internal/httpapi/manage/templates.go

1509 lines
61 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" 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>MORZ Infoboard Admin</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
.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">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="adminNavbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="adminNavbar" 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-white is-outlined is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Lösch-Bestätigungs-Modal (Screens) -->
<div id="delete-modal" class="modal">
<div class="modal-background" onclick="closeDeleteModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Bildschirm löschen?</p>
<button class="delete" aria-label="Schließen" onclick="closeDeleteModal()"></button>
</header>
<section class="modal-card-body">
<p>Soll <strong id="delete-modal-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">
<form id="delete-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeDeleteModal()">Abbrechen</button>
</footer>
</div>
</div>
<!-- Lösch-Bestätigungs-Modal (User) -->
<div id="delete-user-modal" class="modal">
<div class="modal-background" onclick="closeDeleteUserModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Benutzer löschen?</p>
<button class="delete" aria-label="Schließen" onclick="closeDeleteUserModal()"></button>
</header>
<section class="modal-card-body">
<p>Soll Benutzer <strong id="delete-user-modal-name"></strong> wirklich gelöscht werden?</p>
<p class="help has-text-grey mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
</section>
<footer class="modal-card-foot">
<form id="delete-user-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeDeleteUserModal()">Abbrechen</button>
</footer>
</div>
</div>
<!-- Screen-User-Verwaltungs-Modal -->
<div id="screen-users-modal" class="modal">
<div class="modal-background" onclick="closeScreenUsersModal()"></div>
<div class="modal-card" style="width:600px;max-width:95vw">
<header class="modal-card-head">
<p class="modal-card-title" id="screen-users-modal-title">Benutzer verwalten</p>
<button class="delete" aria-label="Schließen" onclick="closeScreenUsersModal()"></button>
</header>
<section class="modal-card-body" id="screen-users-modal-body">
</section>
<footer class="modal-card-foot">
<button class="button" onclick="closeScreenUsersModal()">Schließen</button>
</footer>
</div>
</div>
<script>
(function() {
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
if (burger) {
burger.addEventListener('click', function() {
var target = document.getElementById(burger.dataset.target);
burger.classList.toggle('is-active');
target.classList.toggle('is-active');
});
}
})();
function openDeleteModal(action, name) {
document.getElementById('delete-modal-form').action = action;
document.getElementById('delete-modal-name').textContent = name;
document.getElementById('delete-modal').classList.add('is-active');
}
function closeDeleteModal() {
document.getElementById('delete-modal').classList.remove('is-active');
}
function openDeleteUserModal(action, name) {
document.getElementById('delete-user-modal-form').action = action;
document.getElementById('delete-user-modal-name').textContent = name;
document.getElementById('delete-user-modal').classList.add('is-active');
}
function closeDeleteUserModal() {
document.getElementById('delete-user-modal').classList.remove('is-active');
}
function openScreenUsersModal(screenId, screenName, html) {
document.getElementById('screen-users-modal-title').textContent = 'Benutzer: ' + screenName;
document.getElementById('screen-users-modal-body').innerHTML = html;
document.getElementById('screen-users-modal').classList.add('is-active');
// Re-inject CSRF tokens into newly added forms
injectCSRFNow();
}
function closeScreenUsersModal() {
document.getElementById('screen-users-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDeleteModal();
closeDeleteUserModal();
closeScreenUsersModal();
}
});
</script>
<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.',
'user_added': '✓ Benutzer angelegt.',
'user_deleted': '✓ Benutzer gelöscht.',
'user_added_to_screen': '✓ Benutzer zum Screen hinzugefügt.',
'user_removed_from_screen': '✓ Benutzer vom Screen entfernt.',
'error_empty': '⚠ Benutzername und Passwort erforderlich.',
'error_exists': '⚠ Benutzername bereits vergeben.',
'error_db': '⚠ Datenbankfehler.'
};
var isError = msg.startsWith('error_');
var text = texts[msg] || '✓ Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification ' + (isError ? 'is-warning' : '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);
var url = new URL(window.location.href);
url.searchParams.delete('msg');
history.replaceState(null, '', url.toString());
})();
</script>
<section class="section pb-0 pt-3">
<div class="container">
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li class="is-active"><a href="#" aria-current="page">Admin</a></li>
</ul>
</nav>
</div>
</section>
<section class="section pt-2">
<div class="container">
<!-- Tabs -->
<div class="tabs is-boxed mb-0">
<ul>
<li id="tab-screens" class="{{if eq .ActiveTab "screens"}}is-active{{end}}">
<a><button type="button" role="tab" aria-selected="{{if eq .ActiveTab "screens"}}true{{else}}false{{end}}" onclick="switchTab('screens')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">Bildschirme</button></a>
</li>
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
<a><button type="button" role="tab" aria-selected="{{if eq .ActiveTab "users"}}true{{else}}false{{end}}" onclick="switchTab('users')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">Benutzer</button></a>
</li>
</ul>
</div>
<script>
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');
var btn = li.querySelector('[role="tab"]');
if (btn) btn.setAttribute('aria-selected', 'false');
});
document.getElementById('panel-' + name).classList.add('is-active');
var tabLi = document.getElementById('tab-' + name);
tabLi.classList.add('is-active');
var activeBtn = tabLi.querySelector('[role="tab"]');
if (activeBtn) activeBtn.setAttribute('aria-selected', 'true');
var url = new URL(window.location.href);
url.searchParams.set('tab', name);
history.replaceState(null, '', url.toString());
}
document.addEventListener('DOMContentLoaded', function() {
var active = '{{.ActiveTab}}';
switchTab(active || 'screens');
});
</script>
<!-- Panel: Bildschirme -->
<div id="panel-screens" class="tab-panel box" style="border-radius:0 4px 4px 4px">
<h2 class="title is-5">Bildschirme</h2>
{{if .Screens}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Format</th>
<th>Status</th>
<th>Benutzer</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Screens}}
{{$users := index $.ScreenUserMap .ID}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td>
<td id="status-{{.Slug}}"><span class="has-text-grey" aria-label="Status unbekannt">⚪</span></td>
<td>
{{$screenID := .ID}}
{{$screenName := .Name}}
<button class="button is-small is-light"
type="button"
data-screen-id="{{$screenID}}"
data-screen-name="{{$screenName}}"
onclick="openScreenUsersModal('{{$screenID}}', {{$screenName | printf "%q"}}, buildScreenUsersHTML('{{$screenID}}', {{$screenName | printf "%q"}}))">
{{len $users}} Benutzer
</button>
</td>
<td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp;
<button class="button is-small is-danger is-outlined"
type="button"
aria-label="Bildschirm {{.Name}} löschen"
onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="notification is-light">Noch keine Bildschirme angelegt. Füge unten den ersten hinzu.</div>
{{end}}
<hr>
<h2 class="title is-5">Neuen Bildschirm einrichten</h2>
<p class="mb-4 has-text-grey">
Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst
eine <strong>Schritt-für-Schritt-Anleitung</strong> mit allen nötigen Befehlen
für das Ansible-Deployment.
</p>
<form method="POST" action="/admin/screens/provision">
<div class="columns is-multiline">
<div class="column is-3">
<div class="field">
<label class="label">Slug / Hostname</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. info12" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
<p class="help">Eindeutig, URL-sicher — wird als <code>screen_id</code> verwendet</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Anzeigename</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Kantine EG" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">IP-Adresse</label>
<div class="control">
<input class="input" type="text" name="ip" placeholder="10.0.0.X" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">SSH-User</label>
<div class="control">
<input class="input" type="text" name="ssh_user" placeholder="morz" value="morz">
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
</div>
<button class="button is-primary" type="submit">Anlegen &amp; Anleitung generieren →</button>
</form>
<details class="mt-4">
<summary style="cursor:pointer;font-weight:600;color:#4a4a4a">Bildschirm nur anlegen (ohne Deployment)</summary>
<p class="help has-text-grey mt-1 mb-0">Legt nur einen Datenbank-Eintrag an — kein Ansible, kein Agent-Setup. Für Bildschirme, die bereits provisioniert sind oder manuell konfiguriert werden.</p>
<form method="POST" action="/admin/screens" class="mt-4">
<div class="columns is-vcentered">
<div class="column is-3">
<div class="field">
<label class="label">Slug</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-outlined is-fullwidth" type="submit">Nur anlegen</button>
</div>
</div>
</div>
</form>
</details>
</div><!-- /panel-screens -->
<!-- Panel: Benutzer -->
<div id="panel-users" class="tab-panel box" style="border-radius:0 4px 4px 4px">
<h2 class="title is-5">Screen-Benutzer</h2>
<p class="has-text-grey mb-4">Screen-Benutzer können sich einloggen und nur ihre zugeordneten Bildschirme verwalten.</p>
{{if .ScreenUsers}}
<table class="table is-fullwidth is-hoverable is-striped mb-5">
<thead>
<tr>
<th>Benutzername</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .ScreenUsers}}
<tr>
<td><strong>{{.Username}}</strong></td>
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td>
<button class="button is-small is-danger is-outlined"
type="button"
onclick="openDeleteUserModal('/admin/users/{{.ID}}/delete', '{{.Username}}')">Löschen</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="notification is-light mb-4">Noch keine Screen-Benutzer angelegt. Lege unten den ersten an.</div>
{{end}}
<hr>
<h3 class="title is-6">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">Benutzername</label>
<div class="control">
<input class="input" type="text" name="username" placeholder="z.B. alice" required
autocomplete="off">
</div>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Passwort</label>
<div class="control has-icons-right">
<input class="input" type="password" id="admin-new-password" name="password" placeholder="Passwort (mind. 8 Zeichen)" required
autocomplete="new-password" minlength="8">
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="togglePwAdmin()" title="Passwort anzeigen/verbergen" aria-label="Passwort anzeigen/verbergen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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">Mindestens 8 Zeichen</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Benutzer anlegen</button>
</div>
</div>
</div>
</form>
</div><!-- /panel-users -->
</div>
</section>
<!-- Eingebettete Screen-User-Daten für das Modal (als JSON-Strings) -->
<script>
var _screenUsers = {{.ScreenUsers | screenUsersJSON}};
var _screenUserMap = {{.ScreenUserMap | screenUserMapJSON}};
function buildScreenUsersHTML(screenId, screenName) {
var users = _screenUserMap[screenId] || [];
var allUsers = _screenUsers || [];
// Bereits zugeordnete User-IDs
var assignedIds = {};
users.forEach(function(u) { assignedIds[u.id] = true; });
// Tabelle der zugeordneten User
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>';
html += '<td><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>';
}
// Dropdown mit verfügbaren Usern
var available = allUsers.filter(function(u) { return !assignedIds[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 escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function injectCSRFNow() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
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);
}
});
}
</script>
<script>
(function() {
fetch('/api/v1/screens/status')
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data || !data.screens) return;
var dots = {
'online': { emoji: '🟢', label: 'Online' },
'degraded': { emoji: '🟡', label: 'Eingeschränkt' },
'offline': { emoji: '🔴', label: 'Offline' }
};
data.screens.forEach(function(s) {
var cell = document.getElementById('status-' + s.screen_id);
if (cell) {
var info = dots[s.derived_state] || { emoji: '⚪', label: 'Unbekannt' };
cell.innerHTML = '<span aria-hidden="true">' + info.emoji + '</span>'
+ '<span class="is-sr-only">' + info.label + '</span>'
+ ' <small>' + s.derived_state + '</small>';
}
});
})
.catch(function() {});
})();
</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 togglePwAdmin() {
var inp = document.getElementById('admin-new-password');
if (inp) inp.type = (inp.type === 'password') ? 'text' : 'password';
}
</script>
<script>
// M2: Auto-open Screen-User-Modal wenn ?screen= in URL vorhanden
document.addEventListener('DOMContentLoaded', function() {
var autoScreen = new URLSearchParams(window.location.search).get('screen');
if (!autoScreen) return;
// Suche den passenden Screen in den eingebetteten Daten
var allScreenData = document.querySelectorAll('[data-screen-id]');
// Fallback: direkt openScreenUsersModal aufrufen falls screenId bekannt
if (typeof openScreenUsersModal === 'function' && typeof buildScreenUsersHTML === 'function') {
// Finde Screenname aus dem Button
var btn = document.querySelector('[data-screen-id="' + autoScreen + '"]');
if (btn) {
var screenName = btn.getAttribute('data-screen-name') || autoScreen;
openScreenUsersModal(autoScreen, screenName, buildScreenUsersHTML(autoScreen, screenName));
}
}
// URL-Parameter entfernen ohne Reload
var url = new URL(window.location.href);
url.searchParams.delete('screen');
history.replaceState(null, '', url.toString());
});
</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>`