Implement Phase 6A visual management dashboard

Build the desktop management surface at / while leaving the verified
playback engine and display renderer untouched. Opening the dashboard goes
straight to the playlist that is actually active, with slide imagery
dominating rather than an admin table.

Playlist selection and activation are deliberately separate. Choosing a
playlist only changes what is being edited; an inactive playlist offers an
explicit Activate Playlist action. Activation remains the server's
decision: when playback queues the request to the current slide boundary
the header shows STARTING SOON and keeps the old playlist marked active
until authoritative state promotes it, so the UI never fakes activation
timing.

Entries render as large tiles in a responsive grid where position is the
sequence, so no ordinal numbers are stamped on slides. Names drop file
extensions and are uppercased in Roboto for presentation only; stored
display names keep their case. Status rings are green, gray, amber, or red
and are always accompanied by the same status as text, so nothing depends
on colour alone. Clicking a tile toggles that playlist entry only, never
the library asset or its use in other playlists. Tiles reorder by drag
through dnd-kit, persisting through the existing playlist API and snapping
back to the authoritative order if the server rejects the change. React
identity is always the stable PlaylistEntry UUID, never an index.

Uploads accept PNG, JPEG, and WebP from the picker or a drop anywhere on
the page, run sequentially so appended order matches selection order, and
report per-file state without blocking the dashboard. Content duplicates
and assets already in the playlist are surfaced rather than duplicated.
The Library panel browses and reuses existing assets, and permanent assets
are never shown as stale.

The live preview reuses the display's own presentation resolver and scene
stylesheet, which are extracted to a shared module so the miniature and
the signage output cannot drift apart. It takes on none of the kiosk's
authority: no worker, no offline rotation, no heartbeat, no identity.

Management is desktop-only below 768px, a restriction /display never
inherits.

Compose now attaches only the web service to the external tabernet network
as announcements-web, keeping the API and object storage private.

Verified with 200/200 backend, 20 client unit, and 44 Playwright tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Media
2026-08-17 21:33:04 -05:00
co-authored by Claude Opus 5
parent ba678d08d5
commit 423056e885
20 changed files with 2399 additions and 75 deletions
+98
View File
@@ -20,6 +20,15 @@ Only the Nginx web service publishes a host port (5175 by default). The API and
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
S3 credentials before starting the stack.
The web service also joins the external `tabernet` network under the alias
`announcements-web`, so a shared reverse proxy reaches it as
`announcements-web:80`. The API and SeaweedFS deliberately stay off that
network and remain private to Announcements. The network must already exist:
```sh
docker network create tabernet # only if it does not already exist
```
## Library assets
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
@@ -361,6 +370,95 @@ become Home Assistant notifications. Structured display-side errors such as
decode failures arrive with the Phase 5 renderer, which will report them through
this same field and event.
## Management dashboard
`/` is the management dashboard and `/display` is the signage output. Both come
from the same single-page build; the dashboard's styling is scoped to its own
route and never applies to the kiosk.
Management is desktop-only in v2. Below 768px the dashboard shows a short
splash asking for a larger screen. That restriction never applies to
`/display`, which renders signage on any screen including a phone.
Opening `/` goes straight to the playlist that is actually active — no playlist
has to be chosen first. The header carries the playlist switcher, the
activation state, the display status, and the upload and library actions.
### Playlists and activation
The switcher changes which playlist is being *edited*. Selecting a playlist
never activates it: an inactive playlist shows `NOT ACTIVE` with an
**Activate Playlist** button, and activation goes through
`POST /api/playlists/:playlistId/activate`.
Activation is the server's decision, not the dashboard's. If playback queues the
request to the end of the current slide, the header shows `STARTING SOON` and
keeps showing the old playlist as active until authoritative playback promotes
it. The dashboard never claims a playlist is active before the server says so.
Basic playlist creation is available from the header.
### Slide tiles
Entries render as large visual tiles in a responsive grid. Position in the grid
is the sequence, so tiles carry no ordinal numbers. Names are shown without file
extensions and uppercased visually in Roboto; the stored display name keeps its
original case.
Each tile carries a status ring, always accompanied by the same status in text
so nothing depends on colour alone:
| Ring | Meaning |
| --- | --- |
| Green | Enabled and currently playable |
| Gray | Disabled |
| Amber | Enabled but outside its scheduling window |
| Red | The asset is unavailable or expired |
Clicking a tile toggles that **playlist entry's** enabled state. It never
changes the library asset, and the same asset stays independently enabled in
other playlists. Tiles are dragged to reorder, and the new order is persisted
through the playlist API; if the server rejects it, the authoritative order is
restored and a message explains why. React identity is always the stable
PlaylistEntry UUID, never an array index or position.
An empty playlist and a playlist whose slides are all currently unplayable are
different situations, and the dashboard says which one it is.
### Uploads and the library
**Upload Slides** accepts PNG, JPEG, and WebP through the file picker or by
dropping files anywhere on the dashboard. Multiple files upload in the order
they were selected and are appended to the end of the playlist. Each file shows
its own state — queued, uploading, processing, added, already in the library,
already in this playlist, or failed — and the dashboard stays usable throughout.
PDFs and other formats are refused before upload.
Content identity is the SHA-256 of the original bytes, so re-uploading the same
image reuses the existing library asset instead of storing another copy. A
matching filename with different content is a different asset and is never
overwritten. An asset can appear in many playlists but only once per playlist.
The **Library** panel browses existing assets with a name filter, so images can
be reused without re-uploading. Assets already in the current playlist are shown
as such and cannot be added twice. An asset with no expiration is permanent and
is never presented as stale; one with an expiration shows when it expires.
### Live preview and status
The dashboard shows a miniature of the real signage output driven by
authoritative `playback:state`. It is not a bare thumbnail: it reuses the same
presentation resolver and the same scene stylesheet as `/display`, so 16:9 fill,
uncropped containment, blurred, solid, and black backgrounds, darkening,
foreground shadow, static targets, and the mode-off black gate all render with
identical semantics and cannot drift apart. The preview deliberately does not
take on kiosk responsibilities — no Service Worker, no offline rotation, no
heartbeat, and no display identity of its own.
Alongside it, one compact indicator reports whether the kiosk is actually
online, with last-seen and cached-slide detail, plus the current transport state
(rotating, paused, static, or output off). Transport is read-only here; MQTT and
the API remain the control paths.
## Signage display
`/display` is the signage output and `/` is reserved for the future management