morz-infoboard/server/backend/internal/httpapi/manage/templates.go
Jesko Anschütz 803f355220 Baue Ebene 2: PostgreSQL-Backend, Medien-Upload und Playlist-UI
- DB-Package mit pgxpool, Migrations-Runner und eingebetteten SQL-Dateien
- Schema: tenants, screens, media_assets, playlists, playlist_items
- Store-Layer: alle Repositories (TenantStore, ScreenStore, MediaStore, PlaylistStore)
- JSON-API: Screens, Medien, Playlist-CRUD, Player-Sync-Endpunkt
- Admin-UI (/admin): Screens anlegen, löschen, zur Playlist navigieren
- Playlist-UI (/manage/{slug}): Drag&Drop-Sortierung, Item-Bearbeitung,
  Medienbibliothek, Datei-Upload (Bild/Video/PDF) und Web-URL
- Router auf RouterDeps umgestellt; manage-Routen nur wenn Stores vorhanden
- parseOptionalTime akzeptiert nun RFC3339 und datetime-local HTML-Format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:53:00 +01:00

422 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package manage
const 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="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<style>
body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
</div>
</nav>
<section class="section pt-0">
<div class="container">
<div class="box">
<h2 class="title is-5">Bildschirme</h2>
{{if .Screens}}
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Format</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Screens}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td>
<td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp;
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}}
</div>
<div class="box">
<h2 class="title is-5">Neuer Bildschirm</h2>
<form method="POST" action="/admin/screens">
<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>
<p class="help">URL-sichere Kennung (eindeutig)</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">&nbsp;</label>
<div class="control">
<button class="button is-primary is-fullwidth" type="submit">Erstellen</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</section>
</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="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/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">
<div class="navbar-brand">
<a class="navbar-item" href="/admin">← Admin</a>
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</span>
</div>
</nav>
<section class="section pt-4">
<div class="container">
<!-- ── Playlist ── -->
<div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}}
<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" title="Ziehen zum Sortieren">⠿</td>
<td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td>
<td>
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
</td>
<td>{{.DurationSeconds}}&thinsp;s</td>
<td>
{{if .Enabled}}
<span class="tag is-success is-light">Aktiv</span>
{{else}}
<span class="tag is-warning is-light">Deaktiviert</span>
{{end}}
</td>
<td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete"
style="display:inline"
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')">
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen">✕</button>
</form>
</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="select is-small">
<select name="enabled">
<option value="true"{{if .Enabled}} selected{{end}}>Ja</option>
<option value="false"{{if not .Enabled}} selected{{end}}>Nein</option>
</select>
</div>
</div>
<div class="column is-narrow">
<label class="label is-small">&nbsp;</label>
<div class="buttons">
<button class="button is-small is-success" type="submit">Speichern</button>
<button class="button is-small" type="button" onclick="toggleEdit('{{.ID}}')">Abbrechen</button>
</div>
</div>
</div>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}}
<div class="notification is-light">
Die Playlist ist noch leer. Füge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
</div>
{{end}}
</div>
<!-- ── Medienbibliothek ── -->
<div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}}
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th style="width:5rem">Typ</th>
<th>Titel</th>
<th>Quelle</th>
<th style="width:14rem">Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td><span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span></td>
<td>{{.Title}}</td>
<td>
<small class="has-text-grey">
{{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
</small>
</td>
<td>
{{if index $.AddedAssets .ID}}
<span class="tag is-success is-light mr-2">✓ In Playlist</span>
{{else}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="display:inline">
<input type="hidden" name="media_asset_id" value="{{.ID}}">
<button class="button is-small is-primary" type="submit">+ Hinzufügen</button>
</form>
&nbsp;
{{end}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete"
style="display:inline"
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')">
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{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 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" required
accept="image/*,video/*,application/pdf">
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hochladen</button>
</div>
</div>
</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">&nbsp;</label>
<button class="button is-primary" type="submit">Hinzufügen</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
<script>
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 (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)
});
}
});
}
</script>
</body>
</html>`