UX Block 1: Flash-Messages, Screen-Status, Responsive-Tabellen, Navbar-Burger

- Flash-Messages nach allen Manage-Aktionen (Upload, Löschen, Speichern, Hinzufügen)
- Screen-Online/Offline-Status als farbiger Punkt in Admin-Tabelle
- overflow-x Wrapper für alle Tabellen (Admin, Playlist, Medienbibliothek)
- Navbar-Burger für mobile Viewports in Admin und Manage
- UX-Gestaltungsplan als Sektion in TODO.md eingetragen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 10:55:15 +01:00
parent f11bd4f6c4
commit 883a8146c5
3 changed files with 158 additions and 9 deletions

25
TODO.md
View file

@ -134,6 +134,31 @@
- [ ] Update- und Release-Prozess festlegen - [ ] Update- und Release-Prozess festlegen
- [ ] Langfristige Wayland-Neubewertung fuer spaetere Version vormerken - [ ] Langfristige Wayland-Neubewertung fuer spaetere Version vormerken
## UX-Verbesserungen (Gestaltungsplan)
### Hohe Prioritaet
- [ ] Flash-Messages nach Aktionen in Manage-UI (Upload, Loeschen, Speichern) — Feedback fuer den Nutzer
- [ ] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen)
- [ ] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens)
### Mittlere Prioritaet
- [ ] Loesch-Bestaetigung: Bulma-Modal statt browser-nativer confirm()-Dialog
- [ ] Status-Page: Sprache von Englisch auf Deutsch vereinheitlichen
- [ ] Status-Page: Relative Zeitstempel statt RFC3339 ("vor 2 Minuten")
- [ ] Querlinks zwischen Admin-UI und Status-Page (Navigation)
- [ ] Bulma und SortableJS als lokale Assets einbetten statt CDN
- [ ] Player-UI: CSS-Transitions fuer sanfte Content-Wechsel (Fade statt abrupt)
- [ ] Player-UI: Erweitertes Sysinfo-Overlay (aktueller Titel, Playlist-Laenge)
- [ ] Aria-Labels fuer Loesch-Buttons und Drag-Handles (Accessibility)
### Niedrige Prioritaet
- [ ] Upload-Fortschrittsbalken in Manage-UI
- [ ] vars.yml Download-Button in Provision-UI statt Copy-Paste
- [ ] Toggle-Switch statt Ja/Nein-Select fuer Enabled-Feld
## Querschnittsthemen ## Querschnittsthemen
- [ ] Datensicherung fuer Datenbank und Medien einplanen - [ ] Datensicherung fuer Datenbank und Medien einplanen

View file

@ -141,23 +141,74 @@ const adminTmpl = `<!DOCTYPE html>
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar is-dark" role="navigation"> <nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span> <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">
</div>
</div> </div>
</nav> </nav>
<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');
});
}
})();
</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.'
};
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());
})();
</script>
<section class="section pt-0"> <section class="section pt-0">
<div class="container"> <div class="container">
<div class="box"> <div class="box">
<h2 class="title is-5">Bildschirme</h2> <h2 class="title is-5">Bildschirme</h2>
{{if .Screens}} {{if .Screens}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped"> <table class="table is-fullwidth is-hoverable is-striped">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Slug</th> <th>Slug</th>
<th>Format</th> <th>Format</th>
<th>Status</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@ -167,6 +218,7 @@ const adminTmpl = `<!DOCTYPE html>
<td><strong>{{.Name}}</strong></td> <td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td> <td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td> <td>{{orientationLabel .Orientation}}</td>
<td id="status-{{.Slug}}"><span class="has-text-grey"></span></td>
<td> <td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a> <a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp; &nbsp;
@ -179,6 +231,7 @@ const adminTmpl = `<!DOCTYPE html>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p> <p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}} {{end}}
@ -294,6 +347,24 @@ const adminTmpl = `<!DOCTYPE html>
</div> </div>
</section> </section>
<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>
</body> </body>
</html>` </html>`
@ -319,7 +390,7 @@ const manageTmpl = `<!DOCTYPE html>
</head> </head>
<body> <body>
<nav class="navbar is-dark" role="navigation"> <nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="/admin"> Admin</a> <a class="navbar-item" href="/admin"> Admin</a>
<span class="navbar-item"> <span class="navbar-item">
@ -327,9 +398,46 @@ const manageTmpl = `<!DOCTYPE html>
&nbsp; &nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span> <span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</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> </div>
</nav> </nav>
<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());
})();
</script>
<section class="section pt-4"> <section class="section pt-4">
<div class="container"> <div class="container">
@ -337,6 +445,7 @@ const manageTmpl = `<!DOCTYPE html>
<div class="box"> <div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2> <h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}} {{if .Items}}
<div style="overflow-x: auto">
<table class="table is-fullwidth" id="playlist-table"> <table class="table is-fullwidth" id="playlist-table">
<thead> <thead>
<tr> <tr>
@ -423,6 +532,7 @@ const manageTmpl = `<!DOCTYPE html>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p> <p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}} {{else}}
<div class="notification is-light"> <div class="notification is-light">
@ -435,6 +545,7 @@ const manageTmpl = `<!DOCTYPE html>
<div class="box"> <div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2> <h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}} {{if .Assets}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped"> <table class="table is-fullwidth is-hoverable is-striped">
<thead> <thead>
<tr> <tr>
@ -474,6 +585,7 @@ const manageTmpl = `<!DOCTYPE html>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p> <p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
{{end}} {{end}}
@ -563,6 +675,18 @@ const manageTmpl = `<!DOCTYPE html>
</section> </section>
<script> <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) { function toggleEdit(id) {
var row = document.getElementById('edit-' + id); var row = document.getElementById('edit-' + id);
if (row) { if (row) {

View file

@ -161,7 +161,7 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther)
} }
} }
@ -223,7 +223,7 @@ func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin?msg=deleted", http.StatusSeeOther)
} }
} }
@ -289,7 +289,7 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=uploaded", http.StatusSeeOther)
} }
} }
@ -353,7 +353,7 @@ func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, sc
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=added", http.StatusSeeOther)
} }
} }
@ -366,7 +366,7 @@ func HandleDeleteItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther)
} }
} }
@ -419,7 +419,7 @@ func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther)
} }
} }
@ -435,6 +435,6 @@ func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
} }
media.Delete(r.Context(), mediaID) //nolint:errcheck media.Delete(r.Context(), mediaID) //nolint:errcheck
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther)
} }
} }