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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user