1722 lines
86 KiB
Go
1722 lines
86 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>
|
||
<a class="navbar-item" href="/manage">Monitor-Steuerung</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>{{if eq .Role "restricted"}} <span class="tag is-warning is-light is-small ml-2">Eingeschränkt</span>{{end}}</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-3">
|
||
<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-3">
|
||
<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-3">
|
||
<div class="field">
|
||
<label class="label is-small">Rolle</label>
|
||
<div class="control">
|
||
<div class="select is-small is-fullwidth">
|
||
<select name="role">
|
||
<option value="screen_user">Vollzugriff</option>
|
||
<option value="restricted">Eingeschränkt (nur Medien/Playlist)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-3">
|
||
<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">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Playlist – {{.Screen.Name}}</title>
|
||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||
<script src="/static/Sortable.min.js"></script>
|
||
<style>
|
||
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
|
||
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; }
|
||
.navbar { background:var(--nav-bg) !important; }
|
||
.navbar-item,.navbar-link { color:rgba(255,255,255,.85) !important; }
|
||
.navbar-item:hover,.navbar-link:hover { background:rgba(255,255,255,.08) !important; color:#fff !important; }
|
||
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
|
||
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
|
||
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
|
||
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
|
||
.button { border-radius:var(--radius-btn) !important; }
|
||
.input:focus,.select select:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.12); outline:none; }
|
||
/* Two-column layout */
|
||
.manage-layout { display:grid; grid-template-columns:1fr 380px; gap:1.5rem; align-items:start; }
|
||
@media (max-width:900px) { .manage-layout { grid-template-columns:1fr; } }
|
||
/* Playlist item card */
|
||
.pl-item { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm);
|
||
padding:.9rem 1rem; display:flex; align-items:flex-start; gap:.75rem;
|
||
transition:box-shadow .15s; position:relative; }
|
||
.pl-item:hover { box-shadow:var(--shadow-md); }
|
||
.pl-item.is-disabled { opacity:.45; }
|
||
.pl-item + .pl-item { margin-top:.5rem; }
|
||
.drag-handle { cursor:grab; color:#d1d5db; font-size:1.1rem; padding:.1rem .15rem; user-select:none; flex-shrink:0; margin-top:.1rem; }
|
||
.drag-handle:hover { color:#6b7280; }
|
||
.sortable-ghost { background:#f0fdf4 !important; box-shadow:none !important; }
|
||
.pl-item-body { flex:1; min-width:0; }
|
||
.pl-item-top { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:.35rem; }
|
||
.pl-type-badge { font-size:.65rem; text-transform:uppercase; letter-spacing:.06em; background:#f3f4f6; color:#374151; padding:.15em .55em; border-radius:4px; font-weight:700; flex-shrink:0; }
|
||
.pl-title { font-weight:600; font-size:.9rem; cursor:pointer; border:1px solid transparent; border-radius:4px; padding:.1em .3em; background:transparent; min-width:60px; }
|
||
.pl-title:hover { border-color:#d1d5db; background:#f9fafb; }
|
||
.pl-title:focus { border-color:var(--morz-red); background:#fff; outline:none; box-shadow:0 0 0 2px rgba(227,6,19,.15); }
|
||
.pl-meta { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; font-size:.8rem; color:#6b7280; }
|
||
.pl-duration-wrap { display:inline-flex; align-items:center; gap:.25rem; }
|
||
.pl-duration { cursor:pointer; border:1px solid transparent; border-radius:4px; padding:.1em .3em; background:transparent; width:4rem; text-align:center; font-size:.8rem; color:#6b7280; }
|
||
.pl-duration:hover { border-color:#d1d5db; background:#f9fafb; }
|
||
.pl-duration:focus { border-color:var(--morz-red); background:#fff; outline:none; }
|
||
/* Enable toggle */
|
||
.pl-toggle { position:relative; display:inline-flex; align-items:center; cursor:pointer; gap:.4rem; }
|
||
.pl-toggle input { position:absolute; opacity:0; width:0; height:0; }
|
||
.pl-toggle-track { width:32px; height:18px; border-radius:9px; background:#d1d5db; transition:background .2s; flex-shrink:0; }
|
||
.pl-toggle input:checked + .pl-toggle-track { background:#22c55e; }
|
||
.pl-toggle-thumb { position:absolute; width:14px; height:14px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.2); transition:left .2s; left:2px; top:2px; }
|
||
.pl-toggle input:checked ~ .pl-toggle-thumb { left:16px; }
|
||
.pl-toggle-label { font-size:.75rem; color:#6b7280; }
|
||
/* Validity expand */
|
||
.pl-validity-toggle { font-size:.75rem; color:#6b7280; cursor:pointer; border:none; background:none; padding:0; text-decoration:underline dotted; }
|
||
.pl-validity-toggle:hover { color:var(--morz-red); }
|
||
.pl-validity-fields { margin-top:.5rem; display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; }
|
||
.pl-validity-fields .field { margin:0; }
|
||
.pl-validity-fields label { font-size:.7rem; color:#6b7280; display:block; margin-bottom:.2rem; }
|
||
/* Save indicator */
|
||
.save-ok { color:#22c55e; font-size:.8rem; opacity:0; transition:opacity .3s; }
|
||
.save-ok.show { opacity:1; }
|
||
/* Delete btn */
|
||
.pl-delete { position:absolute; top:.6rem; right:.6rem; background:none; border:none; cursor:pointer; color:#d1d5db; font-size:.9rem; padding:.15rem; border-radius:4px; opacity:0; transition:opacity .15s; }
|
||
.pl-item:hover .pl-delete { opacity:1; }
|
||
.pl-delete:hover { color:#ef4444; background:#fef2f2; }
|
||
/* Library */
|
||
.lib-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:.75rem; }
|
||
.lib-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); overflow:hidden; display:flex; flex-direction:column; }
|
||
.lib-thumb { width:100%; height:80px; object-fit:cover; background:#f3f4f6; display:flex; align-items:center; justify-content:center; font-size:2rem; }
|
||
.lib-info { padding:.5rem .6rem; flex:1; }
|
||
.lib-title { font-size:.75rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.lib-badge { font-size:.6rem; text-transform:uppercase; color:#6b7280; }
|
||
.lib-actions { padding:.4rem .6rem; border-top:1px solid #f3f4f6; display:flex; gap:.35rem; }
|
||
/* Upload zone */
|
||
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
|
||
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
|
||
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
|
||
/* Restricted-Medien: standardmäßig ausgeblendet */
|
||
.lib-card[data-owner-restricted="true"] { display: none; }
|
||
.show-restricted .lib-card[data-owner-restricted="true"] { display: flex; }
|
||
/* Screenshot */
|
||
.screen-preview { width:100%; max-height:200px; object-fit:cover; background:#1e293b; display:block; border-radius:var(--radius) var(--radius) 0 0; }
|
||
/* Modal */
|
||
.modal-card { border-radius:var(--radius); overflow:hidden; }
|
||
.modal-card-head { background:var(--nav-bg); }
|
||
.modal-card-title { color:#fff; font-weight:700; }
|
||
/* Toasts */
|
||
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
|
||
.morz-toast.show { transform:translateX(0); }
|
||
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
||
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
|
||
/* Display control box */
|
||
.display-ctrl { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
|
||
.display-state-badge { font-size:.75rem; padding:.2em .65em; border-radius:99px; font-weight:700; }
|
||
.display-state-badge.on { background:#dcfce7; color:#166534; }
|
||
.display-state-badge.off { background:#fee2e2; color:#991b1b; }
|
||
.display-state-badge.unknown { background:#f3f4f6; color:#6b7280; }
|
||
/* Schedule control */
|
||
.schedule-row { display:flex; gap:.75rem; align-items:flex-end; flex-wrap:wrap; margin-top:.75rem; }
|
||
.schedule-row .field { margin:0; }
|
||
.schedule-row label { font-size:.75rem; color:#6b7280; display:block; margin-bottom:.2rem; font-weight:600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||
<div class="navbar-brand">
|
||
{{if .IsAdmin}}<a class="navbar-item" href="{{.BackLink}}" style="font-size:.85rem">{{.BackLabel}}</a>{{end}}
|
||
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> <span style="color:rgba(255,255,255,.85)">{{.Screen.Name}}</span></span>
|
||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="manageNav">
|
||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||
</a>
|
||
</div>
|
||
<div id="manageNav" class="navbar-menu">
|
||
<div class="navbar-start">
|
||
{{if gt (len .AccessibleScreens) 1}}
|
||
<div class="navbar-item has-dropdown is-hoverable">
|
||
<a class="navbar-link">Bildschirm wechseln</a>
|
||
<div class="navbar-dropdown">
|
||
{{range .AccessibleScreens}}
|
||
<a class="navbar-item{{if eq .Slug $.Screen.Slug}} is-active{{end}}" href="/manage/{{.Slug}}">{{.Name}}</a>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
<div class="navbar-end">
|
||
<div class="navbar-item">
|
||
<form method="POST" action="/logout">
|
||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Delete confirm modal -->
|
||
<div id="del-modal" class="modal">
|
||
<div class="modal-background" onclick="document.getElementById('del-modal').classList.remove('is-active')"></div>
|
||
<div class="modal-card">
|
||
<header class="modal-card-head">
|
||
<p class="modal-card-title" id="del-title">Löschen?</p>
|
||
<button class="delete" onclick="document.getElementById('del-modal').classList.remove('is-active')"></button>
|
||
</header>
|
||
<section class="modal-card-body"><p id="del-body"></p></section>
|
||
<footer class="modal-card-foot" style="gap:.5rem">
|
||
<form id="del-form" method="POST"><button class="button is-danger" type="submit">Löschen</button></form>
|
||
<button class="button" onclick="document.getElementById('del-modal').classList.remove('is-active')">Abbrechen</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="section pt-4">
|
||
<div class="container">
|
||
|
||
<!-- Screenshot -->
|
||
<div class="box mb-4" style="padding:0;overflow:hidden">
|
||
<img class="screen-preview"
|
||
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
|
||
alt="Screenshot {{.Screen.Name}}">
|
||
<div style="padding:.75rem 1rem;display:flex;align-items:center;gap:.75rem;background:var(--surface)">
|
||
<span class="tag is-light" style="font-size:.75rem">{{orientationLabel .Screen.Orientation}}</span>
|
||
<span style="font-size:.8rem;color:#6b7280">{{.Screen.Slug}}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="manage-layout">
|
||
|
||
<!-- LEFT: Playlist -->
|
||
<div>
|
||
<div class="box">
|
||
<h2 class="title is-5 mb-4">Playlist</h2>
|
||
|
||
{{if .Items}}
|
||
<div id="sortable-items">
|
||
{{range .Items}}
|
||
<div class="pl-item{{if not .Enabled}} is-disabled{{end}}" id="item-{{.ID}}" data-id="{{.ID}}">
|
||
<span class="drag-handle" role="button" tabindex="0" title="Ziehen zum Sortieren">⠿</span>
|
||
|
||
<div class="pl-item-body">
|
||
<div class="pl-item-top">
|
||
<span class="pl-type-badge">{{typeIcon .Type}} {{.Type}}</span>
|
||
<input class="pl-title"
|
||
value="{{.Title}}"
|
||
placeholder="{{shortSrc .Src}}"
|
||
data-id="{{.ID}}"
|
||
onblur="saveField(this,'title')"
|
||
onkeydown="if(event.key==='Enter'){this.blur()}">
|
||
<span class="save-ok" id="ok-title-{{.ID}}">✓</span>
|
||
</div>
|
||
|
||
<div class="pl-meta">
|
||
<div class="pl-duration-wrap">
|
||
<input class="pl-duration" type="number" min="1" max="3600"
|
||
value="{{.DurationSeconds}}"
|
||
data-id="{{.ID}}"
|
||
onblur="saveField(this,'duration_seconds')"
|
||
onkeydown="if(event.key==='Enter'){this.blur()}"
|
||
title="Anzeigedauer in Sekunden">
|
||
<span>s</span>
|
||
<span class="save-ok" id="ok-dur-{{.ID}}">✓</span>
|
||
</div>
|
||
|
||
<label class="pl-toggle" title="Aktiv / Deaktiviert">
|
||
<input type="checkbox" {{if .Enabled}}checked{{end}} onchange="toggleEnabled('{{.ID}}',this.checked)">
|
||
<span class="pl-toggle-track"></span>
|
||
<span class="pl-toggle-thumb"></span>
|
||
<span class="pl-toggle-label" id="enabled-label-{{.ID}}">{{if .Enabled}}Aktiv{{else}}Aus{{end}}</span>
|
||
</label>
|
||
|
||
{{if or .ValidFrom .ValidUntil}}
|
||
<button class="pl-validity-toggle" type="button" onclick="toggleValidity('{{.ID}}')">
|
||
{{if and .ValidFrom .ValidUntil}}{{formatDateDE .ValidFrom}} – {{formatDateDE .ValidUntil}}{{else if .ValidFrom}}ab {{formatDateDE .ValidFrom}}{{else}}bis {{formatDateDE .ValidUntil}}{{end}}
|
||
</button>
|
||
{{else}}
|
||
<button class="pl-validity-toggle" type="button" onclick="toggleValidity('{{.ID}}')">+ Gültigkeit</button>
|
||
{{end}}
|
||
</div>
|
||
|
||
<div class="pl-validity-fields" id="validity-{{.ID}}" style="display:none">
|
||
<div class="field">
|
||
<label>Gültig ab</label>
|
||
<input class="input is-small" type="datetime-local"
|
||
value="{{formatDT .ValidFrom}}"
|
||
data-id="{{.ID}}"
|
||
onblur="saveField(this,'valid_from')">
|
||
</div>
|
||
<div class="field">
|
||
<label>Gültig bis</label>
|
||
<input class="input is-small" type="datetime-local"
|
||
value="{{formatDT .ValidUntil}}"
|
||
data-id="{{.ID}}"
|
||
onblur="saveField(this,'valid_until')">
|
||
</div>
|
||
<span class="save-ok" id="ok-validity-{{.ID}}">✓</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="pl-delete" type="button"
|
||
title="Aus Playlist entfernen"
|
||
onclick="openDelModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete','Eintrag entfernen?','Eintrag wirklich aus der Playlist entfernen?')">✕</button>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
<p class="help has-text-grey mt-3" style="font-size:.75rem">Per Drag & Drop sortieren oder Felder direkt bearbeiten.</p>
|
||
{{else}}
|
||
<div class="notification is-light">
|
||
Die Playlist ist leer. Füge Medien aus der Bibliothek hinzu.
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Library + Upload -->
|
||
<div>
|
||
<!-- Display control -->
|
||
{{if ne .UserRole "restricted"}}
|
||
<div class="box mb-3">
|
||
<h3 class="title is-6 mb-3">Display</h3>
|
||
<div class="display-ctrl">
|
||
<span id="display-state-badge" class="display-state-badge {{.DisplayState}}">
|
||
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}Unbekannt{{end}}
|
||
</span>
|
||
<button class="button is-small is-success is-light" type="button"
|
||
onclick="sendDisplayCmd('on')">Einschalten</button>
|
||
<button class="button is-small is-danger is-light" type="button"
|
||
onclick="sendDisplayCmd('off')">Ausschalten</button>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
<!-- Schedule control -->
|
||
{{if ne .UserRole "restricted"}}
|
||
<div class="box mb-3">
|
||
<h3 class="title is-6 mb-2">Zeitplan</h3>
|
||
<label class="pl-toggle" title="Zeitplan aktivieren">
|
||
<input type="checkbox" id="schedule-enabled"
|
||
{{if .Schedule.ScheduleEnabled}}checked{{end}}
|
||
onchange="saveSchedule()">
|
||
<span class="pl-toggle-track"></span>
|
||
<span class="pl-toggle-thumb"></span>
|
||
<span class="pl-toggle-label" style="font-size:.8rem">Zeitplan aktiv</span>
|
||
</label>
|
||
<div class="schedule-row">
|
||
<div class="field">
|
||
<label>Einschalten</label>
|
||
<input class="input is-small" type="time" id="power-on-time"
|
||
value="{{.Schedule.PowerOnTime}}"
|
||
onchange="saveSchedule()" style="width:8rem">
|
||
</div>
|
||
<div class="field">
|
||
<label>Ausschalten</label>
|
||
<input class="input is-small" type="time" id="power-off-time"
|
||
value="{{.Schedule.PowerOffTime}}"
|
||
onchange="saveSchedule()" style="width:8rem">
|
||
</div>
|
||
</div>
|
||
<p id="schedule-save-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
||
</div>
|
||
{{end}}
|
||
<!-- Per-Screen Override (Einschalten bis) -->
|
||
{{if ne .UserRole "restricted"}}
|
||
<div class="box mb-3">
|
||
<h3 class="title is-6 mb-2">Einschalten bis (Override)</h3>
|
||
{{if not_expired .Schedule.OverrideOnUntil}}
|
||
<p style="font-size:.875rem;color:#059669;margin-bottom:.5rem">
|
||
⏰ Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}}
|
||
</p>
|
||
<button class="button is-small is-light" type="button"
|
||
onclick="clearScreenOverridePage()">Override aufheben</button>
|
||
{{else}}
|
||
<p style="font-size:.8rem;color:#6b7280;margin-bottom:.5rem">
|
||
Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet.
|
||
</p>
|
||
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||
<input type="datetime-local" id="screen-override-until" class="input is-small" style="width:16rem">
|
||
<button class="button is-small is-success" type="button"
|
||
onclick="setScreenOverridePage()">Setzen</button>
|
||
</div>
|
||
{{end}}
|
||
<p id="screen-override-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
||
</div>
|
||
{{end}}
|
||
<!-- Upload (collapsed) -->
|
||
<div class="box mb-3">
|
||
<details id="upload-details">
|
||
<summary style="cursor:pointer;font-weight:700;font-size:.9rem;list-style:none;display:flex;align-items:center;justify-content:space-between">
|
||
<span>+ Medium hinzufügen</span>
|
||
<span style="font-size:.75rem;color:#9ca3af">▼</span>
|
||
</summary>
|
||
<div style="margin-top:1rem">
|
||
<div class="tabs is-small mb-3" id="upload-tabs">
|
||
<ul>
|
||
<li id="utab-file" class="is-active"><a onclick="switchUploadTab('file')" style="cursor:pointer">📁 Datei</a></li>
|
||
<li id="utab-web"><a onclick="switchUploadTab('web')" style="cursor:pointer">🌐 URL</a></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div id="upanel-file">
|
||
<form id="upload-form" method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
|
||
<div class="field">
|
||
<label class="label is-small">Typ</label>
|
||
<div class="select is-small">
|
||
<select name="type" id="upload-type-sel" onchange="updateAccept(this.value)">
|
||
<option value="image">🖼 Bild</option>
|
||
<option value="video">🎬 Video</option>
|
||
<option value="pdf">📄 PDF</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
|
||
<input class="input is-small" type="text" name="title" placeholder="Aus Dateinamen abgeleitet">
|
||
</div>
|
||
<div class="field">
|
||
<label class="label is-small">Datei</label>
|
||
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('upload-file-inp').click()" ondragover="event.preventDefault();this.classList.add('dragover')" ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
|
||
<p style="font-size:1.5rem">📂</p>
|
||
<p>Klicken oder Datei hierher ziehen</p>
|
||
<p id="drop-filename" style="display:none;color:#374151;font-weight:600"></p>
|
||
</div>
|
||
<input type="file" id="upload-file-inp" name="file" accept="image/*,video/*,application/pdf" style="display:none" onchange="updateDropLabel(this)">
|
||
</div>
|
||
<div id="upload-progress-wrap" style="display:none" class="mt-2">
|
||
<progress id="upload-progress" class="progress is-primary is-small" value="0" max="100">0%</progress>
|
||
</div>
|
||
<div id="upload-error" class="notification is-danger is-light is-small mt-2" style="display:none"></div>
|
||
<button class="button is-primary is-small mt-2" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div id="upanel-web" style="display:none">
|
||
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
|
||
<input type="hidden" name="type" value="web">
|
||
<div class="field">
|
||
<label class="label is-small">URL</label>
|
||
<input class="input is-small" type="url" name="url" placeholder="https://example.com" required>
|
||
</div>
|
||
<div class="field">
|
||
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
|
||
<input class="input is-small" type="text" name="title" placeholder="Anzeigename">
|
||
</div>
|
||
<button class="button is-primary is-small" type="submit">Hinzufügen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Library -->
|
||
<div class="box">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
|
||
<h2 class="title is-6 mb-0">Medienbibliothek</h2>
|
||
{{if ne .UserRole "restricted"}}
|
||
<button id="toggle-restricted-btn" class="button is-small is-light"
|
||
onclick="toggleRestrictedMedia(this)"
|
||
style="font-size:.75rem">
|
||
Alles anzeigen
|
||
</button>
|
||
{{end}}
|
||
</div>
|
||
{{if .Assets}}
|
||
<div class="lib-grid">
|
||
{{range .Assets}}
|
||
<div class="lib-card" data-owner-restricted="{{if eq $.UserRole "restricted"}}false{{else}}{{.OwnerIsRestricted}}{{end}}">
|
||
<div class="lib-thumb">
|
||
{{if eq .Type "image"}}<img src="{{if .StoragePath}}/uploads/{{.StoragePath}}{{else}}{{.OriginalURL}}{{end}}" style="width:100%;height:80px;object-fit:cover" alt="" loading="lazy" onerror="this.style.display='none';this.parentElement.textContent='🖼'">
|
||
{{else if eq .Type "video"}}🎬
|
||
{{else if eq .Type "pdf"}}📄
|
||
{{else}}🌐{{end}}
|
||
</div>
|
||
<div class="lib-info">
|
||
<div class="lib-title" title="{{.Title}}">{{.Title}}</div>
|
||
<div class="lib-badge">{{typeIcon .Type}} {{.Type}}
|
||
{{if .OwnerIsRestricted}}<span class="tag is-info is-light ml-1" style="font-size:.65rem">{{.OwnerUsername}}</span>{{end}}
|
||
{{if and (eq $.UserRole "admin") (eq .CreatedByUserID "")}}<span class="tag is-warning is-light ml-1" style="font-size:.65rem">Kein Besitzer</span>{{end}}
|
||
</div>
|
||
</div>
|
||
<div class="lib-actions">
|
||
{{if index $.AddedAssets .ID}}
|
||
<span style="font-size:.7rem;color:#22c55e;font-weight:600">✓ In Playlist</span>
|
||
{{else}}
|
||
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="flex:1">
|
||
<input type="hidden" name="media_asset_id" value="{{.ID}}">
|
||
<button class="button is-primary is-small is-fullwidth" type="submit">+ Hinzufügen</button>
|
||
</form>
|
||
{{end}}
|
||
<button class="button is-small is-danger is-outlined" type="button"
|
||
title="Aus Bibliothek löschen"
|
||
onclick="openDelModal('/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete','Medium löschen?','Wirklich löschen? Playlist-Einträge bleiben bestehen.')">🗑</button>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<p class="has-text-grey" style="font-size:.875rem">Noch keine Medien. Lade oben eine Datei hoch.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /manage-layout -->
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
var SCREEN_SLUG = '{{.Screen.Slug}}';
|
||
var SERVER_TZ = '{{.ServerTimezone}}';
|
||
|
||
// ─── Navbar burger ───────────────────────────────────────────────
|
||
(function() {
|
||
var b = document.querySelector('.navbar-burger');
|
||
if (b) b.addEventListener('click', function() {
|
||
b.classList.toggle('is-active');
|
||
document.getElementById(b.dataset.target).classList.toggle('is-active');
|
||
});
|
||
})();
|
||
|
||
// ─── Delete modal ────────────────────────────────────────────────
|
||
function openDelModal(action, title, body) {
|
||
document.getElementById('del-form').action = action;
|
||
document.getElementById('del-title').textContent = title;
|
||
document.getElementById('del-body').textContent = body;
|
||
document.getElementById('del-modal').classList.add('is-active');
|
||
}
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') document.getElementById('del-modal').classList.remove('is-active');
|
||
});
|
||
|
||
// ─── CSRF ────────────────────────────────────────────────────────
|
||
function getCsrf() { var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)'); return m ? decodeURIComponent(m[1]) : ''; }
|
||
function injectCSRF() {
|
||
var t = getCsrf(); if (!t) return;
|
||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||
if (!f.querySelector('input[name="csrf_token"]')) { var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i); }
|
||
});
|
||
}
|
||
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
|
||
|
||
// ─── Toast ───────────────────────────────────────────────────────
|
||
function showToast(msg, type) {
|
||
var t = document.createElement('div');
|
||
t.className = 'morz-toast ' + (type || 'is-success');
|
||
t.innerHTML = msg + '<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
|
||
document.body.appendChild(t);
|
||
requestAnimationFrame(function() { t.classList.add('show'); });
|
||
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
|
||
}
|
||
|
||
// ─── Display control ─────────────────────────────────────────────
|
||
function sendDisplayCmd(state) {
|
||
var slug = SCREEN_SLUG;
|
||
fetch('/api/v1/screens/' + slug + '/display', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCsrf(),
|
||
'X-Requested-With': 'fetch'
|
||
},
|
||
body: JSON.stringify({state: state})
|
||
}).then(function(r) {
|
||
if (r.ok) {
|
||
var badge = document.getElementById('display-state-badge');
|
||
if (badge) {
|
||
badge.className = 'display-state-badge ' + state;
|
||
badge.textContent = state === 'on' ? 'An' : 'Aus';
|
||
}
|
||
showToast('Display ' + (state === 'on' ? 'eingeschaltet' : 'ausgeschaltet'), 'is-success');
|
||
} else {
|
||
showToast('Fehler beim Schalten', 'is-danger');
|
||
}
|
||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||
}
|
||
|
||
// ─── Schedule control ────────────────────────────────────────────────────────
|
||
function saveSchedule() {
|
||
var slug = SCREEN_SLUG;
|
||
var enabled = document.getElementById('schedule-enabled').checked;
|
||
var onTime = document.getElementById('power-on-time').value;
|
||
var offTime = document.getElementById('power-off-time').value;
|
||
fetch('/api/v1/screens/' + slug + '/schedule', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCsrf(),
|
||
'X-Requested-With': 'fetch'
|
||
},
|
||
body: JSON.stringify({
|
||
schedule_enabled: enabled,
|
||
power_on_time: onTime,
|
||
power_off_time: offTime
|
||
})
|
||
}).then(function(r) {
|
||
var ok = document.getElementById('schedule-save-ok');
|
||
if (r.ok && ok) {
|
||
ok.classList.add('show');
|
||
setTimeout(function() { ok.classList.remove('show'); }, 2000);
|
||
} else if (!r.ok) {
|
||
showToast('Zeitplan konnte nicht gespeichert werden', 'is-danger');
|
||
}
|
||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||
}
|
||
|
||
function setScreenOverridePage() {
|
||
var val = document.getElementById('screen-override-until').value;
|
||
if (!val) { showToast('Bitte Datum und Uhrzeit angeben', 'is-warning'); return; }
|
||
var dt = new Date(val);
|
||
fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||
body: JSON.stringify({on_until: dt.toISOString()})
|
||
}).then(function(r) {
|
||
if (r.ok) {
|
||
var ok = document.getElementById('screen-override-ok');
|
||
if (ok) { ok.classList.add('show'); setTimeout(function() { ok.classList.remove('show'); }, 2000); }
|
||
} else { showToast('Fehler beim Setzen des Overrides', 'is-danger'); }
|
||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||
}
|
||
|
||
function clearScreenOverridePage() {
|
||
fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||
body: JSON.stringify({on_until: null})
|
||
}).then(function(r) {
|
||
if (r.ok) { location.reload(); }
|
||
else { showToast('Fehler beim Aufheben des Overrides', 'is-danger'); }
|
||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||
}
|
||
|
||
// ─── ?msg= toast ─────────────────────────────────────────────────
|
||
(function() {
|
||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||
if (!msg) return;
|
||
var texts = { uploaded:'✓ Medium hochgeladen.', deleted:'✓ Gelöscht.', saved:'✓ Gespeichert.', added:'✓ Hinzugefügt.' };
|
||
showToast(texts[msg] || '✓ OK');
|
||
history.replaceState(null, '', window.location.pathname);
|
||
})();
|
||
|
||
// ─── Inline field save ───────────────────────────────────────────
|
||
function saveField(el, fieldName) {
|
||
var id = el.dataset.id;
|
||
var params = new URLSearchParams();
|
||
params.set('csrf_token', getCsrf());
|
||
|
||
// Always send all required fields by reading the item's current DOM state
|
||
var item = document.getElementById('item-' + id);
|
||
var titleEl = item.querySelector('.pl-title');
|
||
var durEl = item.querySelector('.pl-duration');
|
||
var togEl = item.querySelector('.pl-toggle input');
|
||
var vfEl = item.querySelector('[onblur*="valid_from"]');
|
||
var vuEl = item.querySelector('[onblur*="valid_until"]');
|
||
|
||
params.set('title', titleEl ? titleEl.value : '');
|
||
params.set('duration_seconds', durEl ? durEl.value : '20');
|
||
params.set('enabled', togEl && togEl.checked ? 'true' : 'false');
|
||
if (vfEl && vfEl.value) params.set('valid_from', vfEl.value);
|
||
if (vuEl && vuEl.value) params.set('valid_until', vuEl.value);
|
||
|
||
fetch('/manage/' + SCREEN_SLUG + '/items/' + id, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'fetch' },
|
||
body: params.toString()
|
||
}).then(function(r) {
|
||
if (r.ok) {
|
||
var okId = fieldName === 'duration_seconds' ? 'ok-dur-' : fieldName.startsWith('valid') ? 'ok-validity-' : 'ok-title-';
|
||
var okEl = document.getElementById(okId + id);
|
||
if (okEl) { okEl.classList.add('show'); setTimeout(function() { okEl.classList.remove('show'); }, 1500); }
|
||
} else {
|
||
showToast('⚠ Speichern fehlgeschlagen.', 'is-danger');
|
||
}
|
||
}).catch(function() { showToast('⚠ Netzwerkfehler.', 'is-danger'); });
|
||
}
|
||
|
||
// ─── Toggle enabled ──────────────────────────────────────────────
|
||
function toggleEnabled(id, checked) {
|
||
var item = document.getElementById('item-' + id);
|
||
if (checked) item.classList.remove('is-disabled'); else item.classList.add('is-disabled');
|
||
var label = document.getElementById('enabled-label-' + id);
|
||
if (label) label.textContent = checked ? 'Aktiv' : 'Aus';
|
||
// Save via saveField using the title input as trigger element proxy
|
||
var titleEl = item.querySelector('.pl-title');
|
||
if (titleEl) saveField(titleEl, 'enabled');
|
||
}
|
||
|
||
// ─── Toggle validity section ─────────────────────────────────────
|
||
function toggleValidity(id) {
|
||
var el = document.getElementById('validity-' + id);
|
||
if (!el) return;
|
||
el.style.display = (el.style.display === 'none' || !el.style.display) ? 'flex' : 'none';
|
||
}
|
||
|
||
// ─── Sortable drag-and-drop ──────────────────────────────────────
|
||
var sortableEl = document.getElementById('sortable-items');
|
||
if (sortableEl) {
|
||
Sortable.create(sortableEl, {
|
||
handle: '.drag-handle',
|
||
animation: 150,
|
||
ghostClass: 'sortable-ghost',
|
||
onEnd: function() {
|
||
var ids = Array.from(sortableEl.querySelectorAll('.pl-item[data-id]')).map(function(el) { return el.dataset.id; });
|
||
fetch('/manage/' + SCREEN_SLUG + '/reorder', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type':'application/json',
|
||
'X-CSRF-Token': getCsrf(),
|
||
'X-Requested-With': 'fetch'
|
||
},
|
||
body: JSON.stringify(ids)
|
||
}).then(function(r) {
|
||
if (!r.ok) { showToast('⚠ Reihenfolge nicht gespeichert.','is-danger'); window.location.reload(); }
|
||
}).catch(function() { showToast('⚠ Netzwerkfehler.','is-danger'); window.location.reload(); });
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Upload tab switch ───────────────────────────────────────────
|
||
function switchUploadTab(name) {
|
||
['file','web'].forEach(function(t) {
|
||
document.getElementById('utab-' + t).classList.toggle('is-active', t === name);
|
||
document.getElementById('upanel-' + t).style.display = t === name ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function updateAccept(type) {
|
||
var inp = document.getElementById('upload-file-inp');
|
||
if (!inp) return;
|
||
inp.accept = type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : 'application/pdf';
|
||
}
|
||
|
||
function updateDropLabel(inp) {
|
||
var p = document.getElementById('drop-filename');
|
||
if (!p) return;
|
||
if (inp.files && inp.files[0]) { p.textContent = inp.files[0].name; p.style.display=''; }
|
||
}
|
||
|
||
function handleDrop(e) {
|
||
e.preventDefault();
|
||
document.getElementById('drop-zone').classList.remove('dragover');
|
||
var inp = document.getElementById('upload-file-inp');
|
||
if (inp && e.dataTransfer.files.length) { inp.files = e.dataTransfer.files; updateDropLabel(inp); }
|
||
}
|
||
|
||
function startUpload() {
|
||
var form = document.getElementById('upload-form');
|
||
var inp = document.getElementById('upload-file-inp');
|
||
var btn = document.getElementById('upload-btn');
|
||
var pg = document.getElementById('upload-progress-wrap');
|
||
var bar = document.getElementById('upload-progress');
|
||
var err = document.getElementById('upload-error');
|
||
err.style.display = 'none';
|
||
if (!inp.files || !inp.files.length) { err.textContent='Bitte zuerst eine Datei auswählen.';err.style.display='';return; }
|
||
var fd = new FormData(form);
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.upload.onprogress = function(e) { if (e.lengthComputable) { bar.value=Math.round(e.loaded/e.total*100); } };
|
||
xhr.onloadstart = function() { btn.style.display='none'; pg.style.display=''; bar.value=0; };
|
||
xhr.onload = function() {
|
||
if (xhr.status >= 200 && xhr.status < 400) { window.location.href='/manage/'+SCREEN_SLUG+'?msg=uploaded'; }
|
||
else { pg.style.display='none'; btn.style.display=''; err.textContent='Upload fehlgeschlagen: '+xhr.responseText; err.style.display=''; }
|
||
};
|
||
xhr.onerror = function() { pg.style.display='none'; btn.style.display=''; err.textContent='Netzwerkfehler.'; err.style.display=''; };
|
||
xhr.open('POST', form.action);
|
||
xhr.send(fd);
|
||
}
|
||
|
||
// ─── Screenshot lazy-load ────────────────────────────────────────
|
||
(function() {
|
||
document.querySelectorAll('.screen-preview').forEach(function(img) {
|
||
img.src = img.dataset.src;
|
||
setTimeout(function() { img.src = img.dataset.src + '?t=' + Date.now(); }, 4000);
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
|
||
const screenOverviewTmpl = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Meine Bildschirme – MORZ Infoboard</title>
|
||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||
<style>
|
||
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
|
||
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; min-height:100vh; }
|
||
.navbar { background:var(--nav-bg) !important; }
|
||
.navbar-item { color:rgba(255,255,255,.85) !important; }
|
||
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
|
||
.screen-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); overflow:hidden; display:flex; flex-direction:column; text-decoration:none; color:inherit; transition:box-shadow .15s, transform .15s; }
|
||
.screen-card:hover { box-shadow:var(--shadow-md); transform:translateY(-2px); }
|
||
.screen-thumb-wrap { position:relative; }
|
||
.screen-thumb { width:100%; height:180px; object-fit:cover; background:#1e293b; display:block; }
|
||
.screen-status-dot { position:absolute; top:.65rem; right:.65rem; width:12px; height:12px; border-radius:50%; border:2px solid rgba(255,255,255,.8); background:#9ca3af; }
|
||
.screen-status-dot.online { background:#22c55e; }
|
||
.screen-status-dot.stale { background:#f59e0b; }
|
||
.screen-status-dot.offline { background:#ef4444; }
|
||
.screen-card-body { padding:1rem; }
|
||
.screen-card-name { font-weight:700; font-size:1rem; margin-bottom:.25rem; display:flex; align-items:center; gap:.4rem; }
|
||
.screen-card-sub { font-size:.8rem; color:#6b7280; margin-bottom:.85rem; }
|
||
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
|
||
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
|
||
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
|
||
.morz-toast.show { transform:translateX(0); }
|
||
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
||
.display-btn-row { display:flex; gap:.4rem; margin-top:.5rem; }
|
||
.bulk-bar { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:.85rem 1rem; margin-bottom:1.25rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
|
||
.display-state-badge { font-size:.7rem; padding:.15em .55em; border-radius:99px; font-weight:700; }
|
||
.display-state-badge.on { background:#dcfce7; color:#166534; }
|
||
.display-state-badge.off { background:#fee2e2; color:#991b1b; }
|
||
.display-state-badge.unknown { background:#f3f4f6; color:#6b7280; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="navbar" role="navigation">
|
||
<div class="navbar-brand">
|
||
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
|
||
</div>
|
||
<div class="navbar-menu">
|
||
<div class="navbar-end">
|
||
<div class="navbar-item">
|
||
<form method="POST" action="/logout">
|
||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<section class="section">
|
||
<div class="container">
|
||
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
||
<!-- Globaler Override-Banner -->
|
||
{{if ne .UserRole "restricted"}}
|
||
<div id="global-override-section" style="margin-bottom:1rem">
|
||
{{if .GlobalOverride}}
|
||
<div class="notification {{if eq .GlobalOverride.Type "off"}}is-warning{{else}}is-info{{end}} is-light py-2 px-3" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||
<span>
|
||
Alle Monitore <strong>{{if eq .GlobalOverride.Type "off"}}ausgeschaltet{{else}}eingeschaltet{{end}}</strong>
|
||
bis {{.GlobalOverride.Until.Format "02.01.2006 15:04"}}
|
||
</span>
|
||
<button class="button is-small" type="button" onclick="deleteGlobalOverride()">Override aufheben</button>
|
||
</div>
|
||
{{else}}
|
||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||
<button class="button is-small is-danger is-light" type="button" onclick="showGlobalOverrideForm('off')">Alle ausschalten bis…</button>
|
||
<button class="button is-small is-success is-light" type="button" onclick="showGlobalOverrideForm('on')">Alle einschalten bis…</button>
|
||
<span id="override-result" style="font-size:.8rem;color:#6b7280"></span>
|
||
</div>
|
||
<div id="global-override-form" style="display:none;margin-top:.5rem;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||
<input type="datetime-local" id="global-override-until" class="input is-small" style="width:16rem">
|
||
<button class="button is-small is-primary" type="button" onclick="setGlobalOverride()">Setzen</button>
|
||
<button class="button is-small is-light" type="button" onclick="hideGlobalOverrideForm()">Abbrechen</button>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{if and (gt (len .Cards) 1) (ne .UserRole "restricted")}}
|
||
<div class="bulk-bar">
|
||
<span style="font-size:.875rem;font-weight:600;color:#374151">Alle Displays:</span>
|
||
<button class="button is-small is-success is-light" type="button" onclick="bulkDisplay('on')">Alle einschalten</button>
|
||
<button class="button is-small is-danger is-light" type="button" onclick="bulkDisplay('off')">Alle ausschalten</button>
|
||
<span id="bulk-result" style="font-size:.8rem;color:#6b7280"></span>
|
||
</div>
|
||
{{end}}
|
||
<div class="columns is-multiline">
|
||
{{range .Cards}}
|
||
<div class="column is-one-third-desktop is-half-tablet">
|
||
<div class="screen-card">
|
||
<div class="screen-thumb-wrap">
|
||
<img class="screen-thumb" data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot" alt="{{.Screen.Name}}">
|
||
<span class="screen-status-dot unknown" id="status-{{.Screen.Slug}}" title="Unbekannt"></span>
|
||
</div>
|
||
<div class="screen-card-body">
|
||
<div class="screen-card-name">
|
||
{{if eq .Screen.Orientation "portrait"}}📱{{else}}🖥{{end}}
|
||
{{.Screen.Name}}
|
||
</div>
|
||
<div class="screen-card-sub">{{orientationLabel .Screen.Orientation}} · {{.Screen.Slug}}</div>
|
||
<a class="button is-primary is-fullwidth" href="/manage/{{.Screen.Slug}}">Verwalten →</a>
|
||
<div class="display-btn-row">
|
||
<span id="ds-{{.Screen.Slug}}" class="display-state-badge {{.DisplayState}}">
|
||
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}?{{end}}
|
||
</span>
|
||
{{if ne $.UserRole "restricted"}}
|
||
<button class="button is-small is-success is-light" type="button"
|
||
onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
|
||
<button class="button is-small is-danger is-light" type="button"
|
||
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
||
{{end}}
|
||
</div>
|
||
<!-- Per-Screen Override -->
|
||
<div style="margin-top:.5rem;font-size:.8rem">
|
||
{{if ne $.UserRole "restricted"}}
|
||
{{if .OverrideOnUntil}}
|
||
<span style="color:#059669">⏰ Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}}</span>
|
||
<button class="button is-small is-light" style="padding:0 .4rem;height:1.4rem" type="button"
|
||
onclick="clearScreenOverride('{{.Screen.Slug}}')">✕</button>
|
||
{{else}}
|
||
<details style="display:inline">
|
||
<summary style="cursor:pointer;color:#6b7280">Einschalten bis…</summary>
|
||
<div style="display:flex;gap:.3rem;align-items:center;margin-top:.3rem;flex-wrap:wrap">
|
||
<input type="datetime-local" id="override-until-{{.Screen.Slug}}"
|
||
class="input is-small" style="width:13rem;font-size:.75rem">
|
||
<button class="button is-small is-success is-light" type="button"
|
||
onclick="setScreenOverride('{{.Screen.Slug}}')">Setzen</button>
|
||
</div>
|
||
</details>
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
(function() {
|
||
document.querySelectorAll('.screen-thumb').forEach(function(img) {
|
||
img.src = img.dataset.src;
|
||
setTimeout(function() { img.src = img.dataset.src + '?t=' + Date.now(); }, 4000);
|
||
});
|
||
})();
|
||
|
||
(function() {
|
||
function update() {
|
||
fetch('/api/v1/screens/status').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
|
||
if (!data || !data.screens) return;
|
||
data.screens.forEach(function(s) {
|
||
var el = document.getElementById('status-' + s.screen_id);
|
||
if (!el) return;
|
||
var st = s.derived_state || 'unknown';
|
||
el.className = 'screen-status-dot ' + (st === 'online' ? 'online' : st === 'degraded' ? 'stale' : 'offline');
|
||
el.title = st;
|
||
});
|
||
}).catch(function(){});
|
||
}
|
||
update(); setInterval(update, 30000);
|
||
})();
|
||
|
||
function getCsrf() { var m = document.cookie.match('(?:^|; )morz_csrf=([^;]*)'); return m ? decodeURIComponent(m[1]) : ''; }
|
||
function injectCSRF() {
|
||
var t = getCsrf(); if (!t) return;
|
||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||
if (!f.querySelector('input[name="csrf_token"]')) { var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i); }
|
||
});
|
||
}
|
||
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
|
||
|
||
// ─── Display control ─────────────────────────────────────────────
|
||
function sendDisplayCmd(slug, state) {
|
||
fetch('/api/v1/screens/' + slug + '/display', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCsrf(),
|
||
'X-Requested-With': 'fetch'
|
||
},
|
||
body: JSON.stringify({state: state})
|
||
}).then(function(r) {
|
||
var badge = document.getElementById('ds-' + slug);
|
||
if (r.ok && badge) {
|
||
badge.className = 'display-state-badge ' + state;
|
||
badge.textContent = state === 'on' ? 'An' : 'Aus';
|
||
}
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function bulkDisplay(state) {
|
||
var slugs = [];
|
||
document.querySelectorAll('[id^="ds-"]').forEach(function(el) {
|
||
slugs.push(el.id.replace('ds-', ''));
|
||
});
|
||
var result = document.getElementById('bulk-result');
|
||
var done = 0;
|
||
slugs.forEach(function(slug) {
|
||
fetch('/api/v1/screens/' + slug + '/display', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCsrf(),
|
||
'X-Requested-With': 'fetch'
|
||
},
|
||
body: JSON.stringify({state: state})
|
||
}).then(function(r) {
|
||
if (r.ok) {
|
||
var badge = document.getElementById('ds-' + slug);
|
||
if (badge) {
|
||
badge.className = 'display-state-badge ' + state;
|
||
badge.textContent = state === 'on' ? 'An' : 'Aus';
|
||
}
|
||
done++;
|
||
if (result) result.textContent = done + '/' + slugs.length + ' geschaltet';
|
||
}
|
||
}).catch(function(){});
|
||
});
|
||
}
|
||
|
||
var _globalOverrideType = '';
|
||
|
||
function showGlobalOverrideForm(type) {
|
||
_globalOverrideType = type;
|
||
var form = document.getElementById('global-override-form');
|
||
if (form) form.style.display = 'flex';
|
||
}
|
||
|
||
function hideGlobalOverrideForm() {
|
||
var form = document.getElementById('global-override-form');
|
||
if (form) form.style.display = 'none';
|
||
}
|
||
|
||
function setGlobalOverride() {
|
||
var val = document.getElementById('global-override-until').value;
|
||
if (!val) {
|
||
document.getElementById('override-result').textContent = 'Bitte Datum und Uhrzeit angeben';
|
||
return;
|
||
}
|
||
var dt = new Date(val);
|
||
fetch('/api/v1/global-override', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||
body: JSON.stringify({type: _globalOverrideType, until: dt.toISOString()})
|
||
}).then(function(r) {
|
||
if (r.ok) { location.reload(); }
|
||
else { document.getElementById('override-result').textContent = 'Fehler beim Setzen'; }
|
||
}).catch(function() { document.getElementById('override-result').textContent = 'Netzwerkfehler'; });
|
||
}
|
||
|
||
function deleteGlobalOverride() {
|
||
fetch('/api/v1/global-override', {
|
||
method: 'DELETE',
|
||
headers: {'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}
|
||
}).then(function(r) {
|
||
if (r.ok) { location.reload(); }
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function setScreenOverride(slug) {
|
||
var val = document.getElementById('override-until-' + slug).value;
|
||
if (!val) return;
|
||
var dt = new Date(val);
|
||
fetch('/api/v1/screens/' + slug + '/override', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||
body: JSON.stringify({on_until: dt.toISOString()})
|
||
}).then(function(r) {
|
||
if (r.ok) { location.reload(); }
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function clearScreenOverride(slug) {
|
||
fetch('/api/v1/screens/' + slug + '/override', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||
body: JSON.stringify({on_until: null})
|
||
}).then(function(r) {
|
||
if (r.ok) { location.reload(); }
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function toggleRestrictedMedia(btn) {
|
||
var showing = btn.dataset.showing === '1';
|
||
showing = !showing;
|
||
btn.dataset.showing = showing ? '1' : '0';
|
||
document.querySelectorAll('.lib-card[data-owner-restricted="true"]').forEach(function(el) {
|
||
el.style.display = showing ? 'flex' : 'none';
|
||
});
|
||
btn.textContent = showing ? 'Einschränken' : 'Alles anzeigen';
|
||
btn.classList.toggle('is-info', showing);
|
||
btn.classList.toggle('is-light', !showing);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>`
|