feat(player): render announcement banners in soundtouch-player too

The design's three-area target model (chooser/app/admin) and the backend
(HandleListAnnouncements' target=app filtering) already supported this, but
nothing in soundtouch-player's frontend called it — chunk 5 only wired the
admin UI. New Announcements Preact component (static/js/components/), mounted
in App() above the main content so it's visible across every page, styled
with the app's existing CSS variables (dark-mode-aware, unlike the admin
UI's hardcoded inline colors). Currently renders nothing, since no
announcement in the list targets "app" yet (only the admin-gate notice,
targeting "admin") — this is just closing the parity gap so a future
app-targeted announcement has somewhere to show up.

Verified end-to-end against a running instance: the component is served
under /app/static/js/components/, app.js references it, and
/api/announcements?target=app responds correctly (empty today).

Refs #419
This commit is contained in:
Tobias Gesellchen
2026-08-08 23:49:57 +02:00
parent 50509ef729
commit d2ba3d757d
3 changed files with 78 additions and 0 deletions
@@ -911,3 +911,34 @@ img { display: block; max-width: 100%; }
animation: fade-in .2s ease;
}
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
/* ── Announcements ───────────────────────────────────────────────────────── */
.announcements-banner {
padding: .5rem 1rem 0;
}
.announcement {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: .75rem;
padding: .6rem .9rem;
margin-bottom: .5rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-size: .85rem;
}
.announcement-dismiss {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
color: var(--text-dim);
flex-shrink: 0;
padding: 0;
}
.announcement-dismiss:hover {
color: var(--text);
}
@@ -13,6 +13,7 @@ import { RadioBrowser } from './components/RadioBrowser.js';
import { Library } from './components/Library.js';
import { PlayURL } from './components/PlayURL.js';
import { TTS } from './components/TTS.js';
import { Announcements } from './components/Announcements.js';
import { api } from './api.js';
const html = htm.bind(h);
@@ -233,6 +234,8 @@ function App() {
</div>
</nav>
<${Announcements} />
<main class="main-content">
${page === 'devices' ? html`
<${DeviceList}
@@ -0,0 +1,44 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
const html = htm.bind(h);
// Announcements renders dismissible banners for the player (target=app),
// mirroring the admin UI's banner (pkg/service/handlers/web/js/script.js,
// fetchAnnouncements/dismissAnnouncement) against the same backend endpoints
// added for #419 — see _/i419/design-admin-area-auth-gate.md. Dismissal is
// recorded server-side (not a client-only localStorage flag), so it stays
// dismissed across sessions/devices.
export function Announcements() {
const [announcements, setAnnouncements] = useState([]);
useEffect(() => {
fetch('/api/announcements?target=app')
.then(res => res.ok ? res.json() : { announcements: [] })
.then(data => setAnnouncements(data.announcements || []))
.catch(err => console.error('Failed to fetch announcements:', err));
}, []);
async function dismiss(id) {
try {
await fetch(`/api/announcements/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
} catch (err) {
console.error('Failed to dismiss announcement:', err);
}
setAnnouncements(prev => prev.filter(a => a.id !== id));
}
if (announcements.length === 0) return null;
return html`
<div class="announcements-banner">
${announcements.map(a => html`
<div class="announcement announcement-${a.level || 'info'}" key=${a.id}>
<span>${a.message}</span>
<button class="announcement-dismiss" onClick=${() => dismiss(a.id)} title="Dismiss">&times;</button>
</div>
`)}
</div>
`;
}