printf "%q" im Go-Template erzeugte Go-quoted Strings ("..."), die als
Teil der screen_id an die DB übergeben wurden. FK-Constraint schlug fehl,
weil die ID mit eingebetteten Quotes keiner screens-Zeile entsprach.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1272 lines
48 KiB
Go
1272 lines
48 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>
|
||
body { background: #f5f5f5; min-height: 100vh; display: flex; flex-direction: column; }
|
||
.login-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="navbar is-dark">
|
||
<div class="navbar-brand">
|
||
<span class="navbar-item"><strong>morz infoboard</strong></span>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="login-wrapper">
|
||
<div class="columns is-centered" style="width:100%">
|
||
<div class="column is-narrow" style="min-width:340px;max-width:420px">
|
||
<div class="box">
|
||
<h1 class="title is-4 has-text-centered mb-5">Anmelden</h1>
|
||
|
||
{{if .Error}}
|
||
<div class="notification is-danger is-light">
|
||
<button class="delete" onclick="this.parentElement.remove()"></button>
|
||
{{.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">
|
||
<label class="label" for="username">Benutzername</label>
|
||
<div class="control has-icons-left">
|
||
<input class="input" type="text" id="username" name="username"
|
||
autocomplete="username" autofocus required>
|
||
<span class="icon is-small is-left">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||
stroke-linejoin="round">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||
<circle cx="12" cy="7" r="4"/>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="label" for="password">Passwort</label>
|
||
<div class="control has-icons-left">
|
||
<input class="input" type="password" id="password" name="password"
|
||
autocomplete="current-password" required>
|
||
<span class="icon is-small is-left">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||
stroke-linejoin="round">
|
||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field mt-5">
|
||
<div class="control">
|
||
<button class="button is-dark is-fullwidth" type="submit">Anmelden</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
body { background: #f5f5f5; }
|
||
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
|
||
overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
|
||
.step-number { background: #3273dc; color: #fff; border-radius: 50%;
|
||
width: 2rem; height: 2rem; display: inline-flex;
|
||
align-items: center; justify-content: center;
|
||
font-weight: bold; margin-right: 0.5rem; flex-shrink: 0; }
|
||
.step { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; }
|
||
.step-body { flex: 1; }
|
||
.copy-btn { cursor: pointer; font-size: 0.75em; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="navbar is-dark">
|
||
<div class="navbar-brand">
|
||
<a class="navbar-item" href="/admin">← Admin</a>
|
||
<span class="navbar-item"><strong>Bildschirm einrichten: {{.Screen.Name}}</strong></span>
|
||
</div>
|
||
</nav>
|
||
|
||
<section class="section">
|
||
<div class="container" style="max-width:860px">
|
||
|
||
<div class="notification is-success is-light">
|
||
<strong>✓ Screen «{{.Screen.Name}}» ({{.Screen.Slug}}) wurde im Backend angelegt.</strong><br>
|
||
Führe die folgenden Schritte auf deinem Ansible-Host aus, um den Bildschirm zu provisionieren.
|
||
</div>
|
||
|
||
<!-- Schritt 1 -->
|
||
<div class="box">
|
||
<div class="step">
|
||
<span class="step-number">1</span>
|
||
<div class="step-body">
|
||
<p class="title is-6">Host zur Ansible-Inventardatei hinzufügen</p>
|
||
<p class="mb-3">Öffne <code>ansible/inventory.yml</code> und füge den Host unter <code>signage_players → hosts</code> ein:</p>
|
||
<pre id="inv"> {{.Screen.Slug}}:</pre>
|
||
<button class="button is-small is-light copy-btn mt-2" onclick="copy('inv')">📋 Kopieren</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schritt 2 -->
|
||
<div class="box">
|
||
<div class="step">
|
||
<span class="step-number">2</span>
|
||
<div class="step-body">
|
||
<p class="title is-6">Host-Variablen anlegen</p>
|
||
<p class="mb-3">Erstelle die Datei <code>ansible/host_vars/{{.Screen.Slug}}/vars.yml</code> mit folgendem Inhalt:</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 id="copy-btn-hostvars" class="button is-small is-light copy-btn" onclick="copy('hostvars', 'copy-btn-hostvars')">📋 Kopieren</button>
|
||
<button class="button is-small is-light" onclick="downloadFile(document.getElementById('hostvars').innerText, 'vars.yml')">⬇ Als Datei herunterladen</button>
|
||
</div>
|
||
<p class="help mt-2">Tipp: <code>mkdir -p ansible/host_vars/{{.Screen.Slug}}</code></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schritt 3 -->
|
||
<div class="box">
|
||
<div class="step">
|
||
<span class="step-number">3</span>
|
||
<div class="step-body">
|
||
<p class="title is-6">SSH-Zugang sicherstellen</p>
|
||
<p>Stelle sicher, dass dein SSH-Key auf dem Zielgerät hinterlegt ist:</p>
|
||
<pre id="sshcopy">ssh-copy-id {{.SSHUser}}@{{.IP}}</pre>
|
||
<button class="button is-small is-light copy-btn mt-2" onclick="copy('sshcopy')">📋 Kopieren</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schritt 4 -->
|
||
<div class="box">
|
||
<div class="step">
|
||
<span class="step-number">4</span>
|
||
<div class="step-body">
|
||
<p class="title is-6">Ansible-Playbook ausführen</p>
|
||
<p class="mb-3">Führe das Playbook vom Projektverzeichnis aus aus. Das installiert den Agent, konfiguriert Chromium und startet den Kiosk-Modus:</p>
|
||
<pre id="playbookcmd">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 mt-2" onclick="copy('playbookcmd')">📋 Kopieren</button>
|
||
<p class="help mt-2">
|
||
Falls du einen Vault-Pass verwendest:
|
||
<code>--vault-password-file ansible/.vault_pass</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schritt 5 -->
|
||
<div class="box">
|
||
<div class="step">
|
||
<span class="step-number">5</span>
|
||
<div class="step-body">
|
||
<p class="title is-6">Fertig — Playlist befüllen</p>
|
||
<p>Nach erfolgreichem Ansible-Lauf meldet sich der Bildschirm automatisch im Backend an und lädt seine Playlist. Jetzt kannst du Inhalte zuweisen:</p>
|
||
<a class="button is-primary mt-3" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten →</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
function copy(id, btnId) {
|
||
var el = document.getElementById(id);
|
||
if (!el) return;
|
||
navigator.clipboard.writeText(el.innerText).then(function() {
|
||
var btn = btnId
|
||
? document.getElementById(btnId)
|
||
: el.nextElementSibling;
|
||
if (!btn) return;
|
||
var orig = btn.textContent;
|
||
btn.textContent = '✓ Kopiert!';
|
||
setTimeout(function() { btn.textContent = orig; }, 1500);
|
||
});
|
||
}
|
||
|
||
function downloadFile(content, filename) {
|
||
var a = document.createElement('a');
|
||
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(content);
|
||
a.download = filename;
|
||
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>MORZ Infoboard – Admin</title>
|
||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||
<style>
|
||
body { background: #f5f5f5; }
|
||
.navbar { margin-bottom: 1.5rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||
<div class="navbar-brand">
|
||
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
|
||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="adminNavbar">
|
||
<span aria-hidden="true"></span>
|
||
<span aria-hidden="true"></span>
|
||
<span aria-hidden="true"></span>
|
||
</a>
|
||
</div>
|
||
<div id="adminNavbar" class="navbar-menu">
|
||
<div class="navbar-start">
|
||
<a class="navbar-item" href="/status">Diagnose</a>
|
||
</div>
|
||
<div class="navbar-end">
|
||
<div class="navbar-item">
|
||
<form method="POST" action="/logout">
|
||
<button class="button is-light is-small" type="submit">Abmelden</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Lösch-Bestätigungs-Modal (Screens) -->
|
||
<div id="delete-modal" class="modal">
|
||
<div class="modal-background" onclick="closeDeleteModal()"></div>
|
||
<div class="modal-card">
|
||
<header class="modal-card-head">
|
||
<p class="modal-card-title">Bildschirm löschen?</p>
|
||
<button class="delete" aria-label="Schließen" onclick="closeDeleteModal()"></button>
|
||
</header>
|
||
<section class="modal-card-body">
|
||
<p>Soll <strong id="delete-modal-name"></strong> wirklich gelöscht werden?</p>
|
||
<p class="has-text-grey is-size-7 mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
|
||
</section>
|
||
<footer class="modal-card-foot">
|
||
<form id="delete-modal-form" method="POST">
|
||
<button class="button is-danger" type="submit">Wirklich löschen</button>
|
||
</form>
|
||
<button class="button" onclick="closeDeleteModal()">Abbrechen</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lösch-Bestätigungs-Modal (User) -->
|
||
<div id="delete-user-modal" class="modal">
|
||
<div class="modal-background" onclick="closeDeleteUserModal()"></div>
|
||
<div class="modal-card">
|
||
<header class="modal-card-head">
|
||
<p class="modal-card-title">Benutzer löschen?</p>
|
||
<button class="delete" aria-label="Schließen" onclick="closeDeleteUserModal()"></button>
|
||
</header>
|
||
<section class="modal-card-body">
|
||
<p>Soll Benutzer <strong id="delete-user-modal-name"></strong> wirklich gelöscht werden?</p>
|
||
<p class="has-text-grey is-size-7 mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
|
||
</section>
|
||
<footer class="modal-card-foot">
|
||
<form id="delete-user-modal-form" method="POST">
|
||
<button class="button is-danger" type="submit">Wirklich löschen</button>
|
||
</form>
|
||
<button class="button" onclick="closeDeleteUserModal()">Abbrechen</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Screen-User-Verwaltungs-Modal -->
|
||
<div id="screen-users-modal" class="modal">
|
||
<div class="modal-background" onclick="closeScreenUsersModal()"></div>
|
||
<div class="modal-card" style="width:600px;max-width:95vw">
|
||
<header class="modal-card-head">
|
||
<p class="modal-card-title" id="screen-users-modal-title">Benutzer verwalten</p>
|
||
<button class="delete" aria-label="Schließen" onclick="closeScreenUsersModal()"></button>
|
||
</header>
|
||
<section class="modal-card-body" id="screen-users-modal-body">
|
||
</section>
|
||
<footer class="modal-card-foot">
|
||
<button class="button" onclick="closeScreenUsersModal()">Schließen</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
|
||
if (burger) {
|
||
burger.addEventListener('click', function() {
|
||
var target = document.getElementById(burger.dataset.target);
|
||
burger.classList.toggle('is-active');
|
||
target.classList.toggle('is-active');
|
||
});
|
||
}
|
||
})();
|
||
|
||
function openDeleteModal(action, name) {
|
||
document.getElementById('delete-modal-form').action = action;
|
||
document.getElementById('delete-modal-name').textContent = name;
|
||
document.getElementById('delete-modal').classList.add('is-active');
|
||
}
|
||
function closeDeleteModal() {
|
||
document.getElementById('delete-modal').classList.remove('is-active');
|
||
}
|
||
|
||
function openDeleteUserModal(action, name) {
|
||
document.getElementById('delete-user-modal-form').action = action;
|
||
document.getElementById('delete-user-modal-name').textContent = name;
|
||
document.getElementById('delete-user-modal').classList.add('is-active');
|
||
}
|
||
function closeDeleteUserModal() {
|
||
document.getElementById('delete-user-modal').classList.remove('is-active');
|
||
}
|
||
|
||
function openScreenUsersModal(screenId, screenName, html) {
|
||
document.getElementById('screen-users-modal-title').textContent = 'Benutzer: ' + screenName;
|
||
document.getElementById('screen-users-modal-body').innerHTML = html;
|
||
document.getElementById('screen-users-modal').classList.add('is-active');
|
||
// Re-inject CSRF tokens into newly added forms
|
||
injectCSRFNow();
|
||
}
|
||
function closeScreenUsersModal() {
|
||
document.getElementById('screen-users-modal').classList.remove('is-active');
|
||
}
|
||
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
closeDeleteModal();
|
||
closeDeleteUserModal();
|
||
closeScreenUsersModal();
|
||
}
|
||
});
|
||
</script>
|
||
<script>
|
||
(function() {
|
||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||
if (!msg) return;
|
||
var texts = {
|
||
'uploaded': '✓ Medium erfolgreich hochgeladen.',
|
||
'deleted': '✓ Erfolgreich gelöscht.',
|
||
'saved': '✓ Änderungen gespeichert.',
|
||
'added': '✓ Erfolgreich hinzugefügt.',
|
||
'user_added': '✓ Benutzer angelegt.',
|
||
'user_deleted': '✓ Benutzer gelöscht.',
|
||
'user_added_to_screen': '✓ Benutzer zum Screen hinzugefügt.',
|
||
'user_removed_from_screen': '✓ Benutzer vom Screen entfernt.',
|
||
'error_empty': '⚠ Benutzername und Passwort erforderlich.',
|
||
'error_exists': '⚠ Benutzername bereits vergeben.',
|
||
'error_db': '⚠ Datenbankfehler.'
|
||
};
|
||
var isError = msg.startsWith('error_');
|
||
var text = texts[msg] || '✓ Aktion erfolgreich.';
|
||
var n = document.createElement('div');
|
||
n.className = 'notification ' + (isError ? 'is-warning' : 'is-success');
|
||
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
|
||
n.innerHTML = '<button class="delete"></button>' + text;
|
||
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
|
||
document.body.appendChild(n);
|
||
setTimeout(function() {
|
||
n.style.transition = 'opacity .5s';
|
||
n.style.opacity = '0';
|
||
setTimeout(function() { n.remove(); }, 500);
|
||
}, 3000);
|
||
var url = new URL(window.location.href);
|
||
url.searchParams.delete('msg');
|
||
history.replaceState(null, '', url.toString());
|
||
})();
|
||
</script>
|
||
|
||
<section class="section pt-0">
|
||
<div class="container">
|
||
|
||
<!-- Tabs -->
|
||
<div class="tabs is-boxed mb-0">
|
||
<ul>
|
||
<li id="tab-screens" class="{{if eq .ActiveTab "screens"}}is-active{{end}}">
|
||
<a onclick="switchTab('screens')">Bildschirme</a>
|
||
</li>
|
||
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
|
||
<a onclick="switchTab('users')">Benutzer</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<script>
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
|
||
document.querySelectorAll('.tabs li').forEach(function(li) { li.classList.remove('is-active'); });
|
||
document.getElementById('panel-' + name).style.display = '';
|
||
document.getElementById('tab-' + name).classList.add('is-active');
|
||
var url = new URL(window.location.href);
|
||
url.searchParams.set('tab', name);
|
||
history.replaceState(null, '', url.toString());
|
||
}
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var active = '{{.ActiveTab}}';
|
||
switchTab(active || 'screens');
|
||
});
|
||
</script>
|
||
|
||
<!-- Panel: Bildschirme -->
|
||
<div id="panel-screens" class="tab-panel box" style="border-radius:0 4px 4px 4px">
|
||
|
||
<h2 class="title is-5">Bildschirme</h2>
|
||
{{if .Screens}}
|
||
<div style="overflow-x: auto">
|
||
<table class="table is-fullwidth is-hoverable is-striped">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Slug</th>
|
||
<th>Format</th>
|
||
<th>Status</th>
|
||
<th>Benutzer</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Screens}}
|
||
{{$users := index $.ScreenUserMap .ID}}
|
||
<tr>
|
||
<td><strong>{{.Name}}</strong></td>
|
||
<td><code>{{.Slug}}</code></td>
|
||
<td>{{orientationLabel .Orientation}}</td>
|
||
<td id="status-{{.Slug}}"><span class="has-text-grey">⚪</span></td>
|
||
<td>
|
||
{{$screenID := .ID}}
|
||
{{$screenName := .Name}}
|
||
<button class="button is-small is-light"
|
||
type="button"
|
||
onclick="openScreenUsersModal('{{$screenID}}', {{$screenName | printf "%q"}}, buildScreenUsersHTML('{{$screenID}}', {{$screenName | printf "%q"}}))">
|
||
{{len $users}} Benutzer
|
||
</button>
|
||
</td>
|
||
<td>
|
||
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
|
||
|
||
<button class="button is-small is-danger is-outlined"
|
||
type="button"
|
||
aria-label="Bildschirm {{.Name}} löschen"
|
||
onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
|
||
{{end}}
|
||
|
||
<hr>
|
||
|
||
<h2 class="title is-5">Neuen Bildschirm einrichten</h2>
|
||
<p class="mb-4 has-text-grey">
|
||
Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst
|
||
eine <strong>Schritt-für-Schritt-Anleitung</strong> mit allen nötigen Befehlen
|
||
für das Ansible-Deployment.
|
||
</p>
|
||
<form method="POST" action="/admin/screens/provision">
|
||
<div class="columns is-multiline">
|
||
<div class="column is-3">
|
||
<div class="field">
|
||
<label class="label">Slug / Hostname</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="slug" placeholder="z.B. info12" required
|
||
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
|
||
</div>
|
||
<p class="help">Eindeutig, URL-sicher — wird als <code>screen_id</code> verwendet</p>
|
||
</div>
|
||
</div>
|
||
<div class="column is-3">
|
||
<div class="field">
|
||
<label class="label">Anzeigename</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="name" placeholder="z.B. Kantine EG" required>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-2">
|
||
<div class="field">
|
||
<label class="label">IP-Adresse</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="ip" placeholder="10.0.0.X" required>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-2">
|
||
<div class="field">
|
||
<label class="label">SSH-User</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="ssh_user" placeholder="morz" value="morz">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-2">
|
||
<div class="field">
|
||
<label class="label">Format</label>
|
||
<div class="control">
|
||
<div class="select is-fullwidth">
|
||
<select name="orientation">
|
||
<option value="landscape">Querformat</option>
|
||
<option value="portrait">Hochformat</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="button is-primary" type="submit">Anlegen & Anleitung generieren →</button>
|
||
</form>
|
||
|
||
<details class="mt-4">
|
||
<summary class="has-text-grey" style="cursor:pointer">Bestehenden Screen manuell anlegen (nur DB-Eintrag, kein Deployment)</summary>
|
||
<form method="POST" action="/admin/screens" class="mt-4">
|
||
<div class="columns is-vcentered">
|
||
<div class="column is-3">
|
||
<div class="field">
|
||
<label class="label">Slug</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
|
||
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-4">
|
||
<div class="field">
|
||
<label class="label">Name</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-2">
|
||
<div class="field">
|
||
<label class="label">Format</label>
|
||
<div class="control">
|
||
<div class="select is-fullwidth">
|
||
<select name="orientation">
|
||
<option value="landscape">Querformat</option>
|
||
<option value="portrait">Hochformat</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-3">
|
||
<div class="field">
|
||
<label class="label"> </label>
|
||
<button class="button is-outlined is-fullwidth" type="submit">Nur anlegen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</details>
|
||
|
||
</div><!-- /panel-screens -->
|
||
|
||
<!-- Panel: Benutzer -->
|
||
<div id="panel-users" class="tab-panel box" style="border-radius:0 4px 4px 4px">
|
||
|
||
<h2 class="title is-5">Screen-Benutzer</h2>
|
||
<p class="has-text-grey mb-4">Screen-Benutzer können sich einloggen und nur ihre zugeordneten Bildschirme verwalten.</p>
|
||
|
||
{{if .ScreenUsers}}
|
||
<table class="table is-fullwidth is-hoverable is-striped mb-5">
|
||
<thead>
|
||
<tr>
|
||
<th>Benutzername</th>
|
||
<th>Erstellt</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .ScreenUsers}}
|
||
<tr>
|
||
<td><strong>{{.Username}}</strong></td>
|
||
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
|
||
<td>
|
||
<button class="button is-small is-danger is-outlined"
|
||
type="button"
|
||
onclick="openDeleteUserModal('/admin/users/{{.ID}}/delete', '{{.Username}}')">Löschen</button>
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
{{else}}
|
||
<p class="has-text-grey mb-4">Noch keine Screen-Benutzer angelegt.</p>
|
||
{{end}}
|
||
|
||
<hr>
|
||
<h3 class="title is-6">Neuen Benutzer anlegen</h3>
|
||
<form method="POST" action="/admin/users">
|
||
<div class="columns is-vcentered">
|
||
<div class="column is-4">
|
||
<div class="field">
|
||
<label class="label">Benutzername</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="username" placeholder="z.B. alice" required
|
||
autocomplete="off">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-4">
|
||
<div class="field">
|
||
<label class="label">Passwort</label>
|
||
<div class="control">
|
||
<input class="input" type="password" name="password" placeholder="Passwort" required
|
||
autocomplete="new-password">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-4">
|
||
<div class="field">
|
||
<label class="label"> </label>
|
||
<button class="button is-primary" type="submit">Benutzer anlegen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
</div><!-- /panel-users -->
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Eingebettete Screen-User-Daten für das Modal (als JSON-Strings) -->
|
||
<script>
|
||
var _screenUsers = {{.ScreenUsers | screenUsersJSON}};
|
||
var _screenUserMap = {{.ScreenUserMap | screenUserMapJSON}};
|
||
|
||
function buildScreenUsersHTML(screenId, screenName) {
|
||
var users = _screenUserMap[screenId] || [];
|
||
var allUsers = _screenUsers || [];
|
||
|
||
// Bereits zugeordnete User-IDs
|
||
var assignedIds = {};
|
||
users.forEach(function(u) { assignedIds[u.id] = true; });
|
||
|
||
// Tabelle der zugeordneten User
|
||
var html = '';
|
||
if (users.length > 0) {
|
||
html += '<table class="table is-fullwidth is-narrow mb-4"><thead><tr><th>Benutzer</th><th></th></tr></thead><tbody>';
|
||
users.forEach(function(u) {
|
||
html += '<tr><td>' + escHtml(u.username) + '</td>';
|
||
html += '<td><form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users/' + escHtml(u.id) + '/remove" style="display:inline">';
|
||
html += '<button class="button is-small is-danger is-outlined" type="submit">Entfernen</button></form></td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="has-text-grey mb-4">Noch keine Benutzer zugeordnet.</p>';
|
||
}
|
||
|
||
// Dropdown mit verfügbaren Usern
|
||
var available = allUsers.filter(function(u) { return !assignedIds[u.id]; });
|
||
if (available.length > 0) {
|
||
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users">';
|
||
html += '<div class="field has-addons">';
|
||
html += '<div class="control is-expanded"><div class="select is-fullwidth"><select name="user_id">';
|
||
available.forEach(function(u) {
|
||
html += '<option value="' + escHtml(u.id) + '">' + escHtml(u.username) + '</option>';
|
||
});
|
||
html += '</select></div></div>';
|
||
html += '<div class="control"><button class="button is-primary" type="submit">Hinzufügen</button></div>';
|
||
html += '</div></form>';
|
||
} else if (allUsers.length === 0) {
|
||
html += '<p class="has-text-grey is-size-7">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
|
||
} else {
|
||
html += '<p class="has-text-grey is-size-7">Alle Benutzer sind bereits zugeordnet.</p>';
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function injectCSRFNow() {
|
||
function getCookie(name) {
|
||
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
||
return m ? decodeURIComponent(m[1]) : '';
|
||
}
|
||
var token = getCookie('morz_csrf');
|
||
if (!token) return;
|
||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||
if (!f.querySelector('input[name="csrf_token"]')) {
|
||
var inp = document.createElement('input');
|
||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||
f.appendChild(inp);
|
||
}
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<script>
|
||
(function() {
|
||
fetch('/api/v1/screens/status')
|
||
.then(function(r) { return r.ok ? r.json() : null; })
|
||
.then(function(data) {
|
||
if (!data || !data.screens) return;
|
||
var dots = { 'online': '🟢', 'degraded': '🟡', 'offline': '🔴' };
|
||
data.screens.forEach(function(s) {
|
||
var cell = document.getElementById('status-' + s.screen_id);
|
||
if (cell) {
|
||
cell.innerHTML = (dots[s.derived_state] || '⚪') + ' <small>' + s.derived_state + '</small>';
|
||
}
|
||
});
|
||
})
|
||
.catch(function() {});
|
||
})();
|
||
</script>
|
||
<script>
|
||
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
|
||
(function() {
|
||
function getCookie(name) {
|
||
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
||
return m ? decodeURIComponent(m[1]) : '';
|
||
}
|
||
function injectCSRF() {
|
||
var token = getCookie('morz_csrf');
|
||
if (!token) return;
|
||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||
if (!f.querySelector('input[name="csrf_token"]')) {
|
||
var inp = document.createElement('input');
|
||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||
f.appendChild(inp);
|
||
}
|
||
});
|
||
}
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', injectCSRF);
|
||
} else {
|
||
injectCSRF();
|
||
}
|
||
})();
|
||
</script>
|
||
</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>
|
||
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: #fffbf0; padding: 0.75rem 1rem; }
|
||
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.sortable-ghost { background: #e8f4fd !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">
|
||
<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>
|
||
<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">
|
||
</div>
|
||
<div class="navbar-end">
|
||
<div class="navbar-item">
|
||
<form method="POST" action="/logout">
|
||
<button class="button is-light 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.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 pt-4">
|
||
<div class="container">
|
||
|
||
<!-- ── 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 class="drag-handle" role="button" aria-label="Reihenfolge ändern" tabindex="0" title="Ziehen zum Sortieren">⠿</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}}
|
||
</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}}">
|
||
</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}}">
|
||
</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}}
|
||
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
|
||
{{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 onclick="switchTab('file')">📁 Datei hochladen</a></li>
|
||
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</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">
|
||
<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 is-size-7">(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 is-size-7">(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) {
|
||
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (p === tab) {
|
||
panel.classList.add('is-active');
|
||
tabEl.classList.add('is-active');
|
||
} else {
|
||
panel.classList.remove('is-active');
|
||
tabEl.classList.remove('is-active');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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)
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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>
|
||
|
||
</body>
|
||
</html>`
|