1381 lines
60 KiB
Go
1381 lines
60 KiB
Go
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 & 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"> </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"> </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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function openDelModal(action, name) {
|
||
document.getElementById('del-form').action = action;
|
||
document.getElementById('del-name').textContent = name;
|
||
document.getElementById('del-modal').classList.add('is-active');
|
||
}
|
||
function openDelUserModal(action, name) {
|
||
document.getElementById('del-user-form').action = action;
|
||
document.getElementById('del-user-name').textContent = name;
|
||
document.getElementById('del-user-modal').classList.add('is-active');
|
||
}
|
||
function openScreenUsersModal(screenId, screenName, html) {
|
||
document.getElementById('su-modal-title').textContent = 'Benutzer: ' + screenName;
|
||
document.getElementById('su-modal-body').innerHTML = html;
|
||
document.getElementById('screen-users-modal').classList.add('is-active');
|
||
injectCSRF();
|
||
}
|
||
function closeModal(id) { document.getElementById(id).classList.remove('is-active'); }
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') ['del-modal','del-user-modal','screen-users-modal'].forEach(function(id) { closeModal(id); });
|
||
});
|
||
|
||
function buildScreenUsersHTML(screenId, screenName) {
|
||
var users = _screenUserMap[screenId] || [];
|
||
var allUsers = _screenUsers || [];
|
||
var assigned = {};
|
||
users.forEach(function(u) { assigned[u.id] = true; });
|
||
var html = '';
|
||
if (users.length > 0) {
|
||
html += '<table class="table is-fullwidth is-narrow mb-4"><thead><tr><th>Benutzer</th><th></th></tr></thead><tbody>';
|
||
users.forEach(function(u) {
|
||
html += '<tr><td>' + escHtml(u.username) + '</td><td>';
|
||
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users/' + escHtml(u.id) + '/remove" style="display:inline">';
|
||
html += '<button class="button is-small is-danger is-outlined" type="submit">Entfernen</button></form></td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="has-text-grey mb-4">Noch keine Benutzer zugeordnet.</p>';
|
||
}
|
||
var available = allUsers.filter(function(u) { return !assigned[u.id]; });
|
||
if (available.length > 0) {
|
||
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users">';
|
||
html += '<div class="field has-addons">';
|
||
html += '<div class="control is-expanded"><div class="select is-fullwidth"><select name="user_id">';
|
||
available.forEach(function(u) { html += '<option value="' + escHtml(u.id) + '">' + escHtml(u.username) + '</option>'; });
|
||
html += '</select></div></div>';
|
||
html += '<div class="control"><button class="button is-primary" type="submit">Hinzufügen</button></div>';
|
||
html += '</div></form>';
|
||
} else if (allUsers.length === 0) {
|
||
html += '<p class="help has-text-grey">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
|
||
} else {
|
||
html += '<p class="help has-text-grey">Alle Benutzer sind bereits zugeordnet.</p>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
|
||
document.querySelectorAll('.tabs li').forEach(function(li) { li.classList.remove('is-active'); });
|
||
document.getElementById('panel-' + name).classList.add('is-active');
|
||
document.getElementById('tab-' + name).classList.add('is-active');
|
||
history.replaceState(null, '', '?tab=' + name);
|
||
}
|
||
|
||
// Navbar burger
|
||
(function() {
|
||
var b = document.querySelector('.navbar-burger');
|
||
if (b) b.addEventListener('click', function() {
|
||
b.classList.toggle('is-active');
|
||
document.getElementById(b.dataset.target).classList.toggle('is-active');
|
||
});
|
||
})();
|
||
|
||
// Toast from ?msg=
|
||
(function() {
|
||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||
if (!msg) return;
|
||
var texts = { uploaded:'✓ Medium hochgeladen.',deleted:'✓ Gelöscht.',saved:'✓ Gespeichert.',added:'✓ Hinzugefügt.',user_added:'✓ Benutzer angelegt.',user_deleted:'✓ Benutzer gelöscht.',user_added_to_screen:'✓ Benutzer zugeordnet.',user_removed_from_screen:'✓ Benutzer entfernt.',error_empty:'⚠ Felder ausfüllen.',error_exists:'⚠ Bereits vergeben.',error_db:'⚠ Datenbankfehler.' };
|
||
var isErr = msg.startsWith('error_');
|
||
showToast(texts[msg] || '✓ Aktion erfolgreich.', isErr ? 'is-warning' : 'is-success');
|
||
history.replaceState(null, '', window.location.pathname + (window.location.search.replace(/[?&]msg=[^&]*/,'') || ''));
|
||
})();
|
||
|
||
// Status dots
|
||
(function() {
|
||
function update() {
|
||
fetch('/api/v1/screens/status').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
|
||
if (!data || !data.screens) return;
|
||
data.screens.forEach(function(s) {
|
||
var el = document.getElementById('status-' + s.screen_id);
|
||
if (!el) return;
|
||
var state = s.derived_state || 'unknown';
|
||
el.className = 'status-dot ' + (state === 'online' ? 'online' : state === 'degraded' ? 'stale' : 'offline');
|
||
el.title = state;
|
||
});
|
||
}).catch(function(){});
|
||
}
|
||
update(); setInterval(update, 30000);
|
||
})();
|
||
|
||
// Auto-open screen users modal from ?screen=
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var sc = new URLSearchParams(window.location.search).get('screen');
|
||
if (!sc) return;
|
||
var btn = document.querySelector('[data-screen-id="' + sc + '"]');
|
||
if (btn) openScreenUsersModal(sc, btn.getAttribute('data-screen-name') || sc, buildScreenUsersHTML(sc, btn.getAttribute('data-screen-name') || sc));
|
||
history.replaceState(null, '', window.location.href.replace(/[?&]screen=[^&]*/,''));
|
||
});
|
||
|
||
function showToast(msg, type) {
|
||
type = type || 'is-success';
|
||
var t = document.createElement('div');
|
||
t.className = 'morz-toast ' + type;
|
||
t.innerHTML = msg + '<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
|
||
document.body.appendChild(t);
|
||
requestAnimationFrame(function() { t.classList.add('show'); });
|
||
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
|
||
}
|
||
|
||
function getCsrfToken() { var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)'); return m ? decodeURIComponent(m[1]) : ''; }
|
||
function injectCSRF() {
|
||
var token = getCsrfToken(); if (!token) return;
|
||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||
if (!f.querySelector('input[name="csrf_token"]')) { var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=token;f.appendChild(i); }
|
||
});
|
||
}
|
||
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
|
||
const manageTmpl = `<!DOCTYPE html>
|
||
<html lang="de" data-theme="light">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="color-scheme" content="light">
|
||
<title>Playlist – {{.Screen.Name}}</title>
|
||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||
<script src="/static/Sortable.min.js"></script>
|
||
<style>
|
||
body { background: #f5f5f5; }
|
||
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
|
||
.drag-handle:hover { color: #333; }
|
||
.item-disabled td { opacity: 0.5; }
|
||
.edit-row td { background: var(--bulma-warning-light, hsl(48, 100%, 96%)); padding: 0.75rem 1rem; }
|
||
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.sortable-ghost { background: var(--bulma-info-light, hsl(207, 61%, 94%)) !important; }
|
||
.tab-panel { display: none; }
|
||
.tab-panel.is-active { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||
<div class="navbar-brand">
|
||
{{if .IsAdmin}}<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>{{end}}
|
||
<span class="navbar-item">
|
||
<strong>{{.Screen.Name}}</strong>
|
||
|
||
<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}} {{.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}} 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"> </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 & 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}} {{.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>
|
||
|
||
{{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"> </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"> </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>`
|