diff --git a/README.md b/README.md
index 3c5b8d9..19ec998 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/client/e2e/dashboard.spec.js b/client/e2e/dashboard.spec.js
new file mode 100644
index 0000000..1d2f33d
--- /dev/null
+++ b/client/e2e/dashboard.spec.js
@@ -0,0 +1,589 @@
+import { expect, test } from "@playwright/test";
+import { ensureFixtures, makePng } from "./fixtures.js";
+import { api, resetPlayback } from "./helpers.js";
+
+const BASE = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
+
+async function freshPlaylist(request, name, assetIds) {
+ const playlist = await api(request, "/api/playlists", {
+ method: "POST",
+ data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 7)}` },
+ });
+ for (const assetId of assetIds) {
+ await api(request, `/api/playlists/${playlist.id}/entries`, {
+ method: "POST",
+ data: { assetId, durationOverrideMs: 60_000 },
+ });
+ }
+ return api(request, `/api/playlists/${playlist.id}`);
+}
+
+async function openDashboardOn(page, playlistName) {
+ await page.goto(`${BASE}/`);
+ await page.waitForSelector('[data-testid="playlist-select"]');
+ await page.selectOption('[data-testid="playlist-select"]', { label: playlistName });
+ await expect(page.locator("h1")).toHaveText(playlistName);
+}
+
+function tiles(page) {
+ return page.locator('[data-testid="tile"]');
+}
+
+test.beforeEach(async ({ request }) => {
+ await ensureFixtures(request);
+ await resetPlayback(request);
+});
+
+test("the dashboard opens on the active playlist and the signage route still works", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Active open", [
+ library["wide-a"].id,
+ library["wide-b"].id,
+ ]);
+ const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
+ method: "POST",
+ });
+ if (activation.activation === "pending") {
+ await api(request, "/api/playback/next", { method: "POST" });
+ }
+
+ await page.goto(`${BASE}/`);
+ // No playlist has to be chosen before something is visible.
+ await expect(page.locator("h1")).toHaveText(playlist.name, { timeout: 30_000 });
+ await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "active");
+ await expect(tiles(page)).toHaveCount(2);
+ await expect(page.getByTestId("scene-preview")).toBeVisible();
+
+ // The signage route is untouched by the dashboard.
+ await page.goto(`${BASE}/display`);
+ await expect(page.getByTestId("display-root")).toBeVisible();
+ await expect(page.getByTestId("dashboard")).toHaveCount(0);
+});
+
+test("selecting another playlist never activates it; activation is explicit", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const active = await freshPlaylist(request, "Stays active", [library["wide-a"].id]);
+ const other = await freshPlaylist(request, "Guest Church", [library["wide-b"].id]);
+ const activation = await api(request, `/api/playlists/${active.id}/activate`, {
+ method: "POST",
+ });
+ if (activation.activation === "pending") {
+ await api(request, "/api/playback/next", { method: "POST" });
+ }
+
+ await openDashboardOn(page, other.name);
+ await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "inactive");
+ await expect(page.getByTestId("activate-button")).toBeVisible();
+
+ // Merely viewing it must not have changed the server's active playlist.
+ expect((await api(request, "/api/playback")).activePlaylistId).toBe(active.id);
+
+ await page.getByTestId("activate-button").click();
+ // The server decides immediate versus queued; the UI must not fake either.
+ await expect
+ .poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), {
+ timeout: 30_000,
+ })
+ .toMatch(/^(active|pending)$/);
+
+ const state = await page.getByTestId("activation-state").getAttribute("data-state");
+ const playback = await api(request, "/api/playback");
+ if (state === "pending") {
+ expect(playback.pendingPlaylistId).toBe(other.id);
+ expect(playback.activePlaylistId).toBe(active.id);
+ // Once the boundary passes, authoritative state promotes it with no reload.
+ await api(request, "/api/playback/next", { method: "POST" });
+ }
+ await expect
+ .poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), {
+ timeout: 45_000,
+ })
+ .toBe("active");
+ expect((await api(request, "/api/playback")).activePlaylistId).toBe(other.id);
+});
+
+test("tiles show artwork, clean names, and accurate status without relying on colour", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Statuses", [
+ library["wide-a"].id,
+ library["wide-b"].id,
+ library["wide-c"].id,
+ ]);
+ await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[1].id}`, {
+ method: "PATCH",
+ data: { enabled: false },
+ });
+ await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[2].id}`, {
+ method: "PATCH",
+ data: { scheduleStartAt: Date.now() + 86_400_000 },
+ });
+
+ await openDashboardOn(page, playlist.name);
+ await expect(tiles(page)).toHaveCount(3);
+
+ const rings = await tiles(page).evaluateAll((nodes) =>
+ nodes.map((node) => ({
+ ring: node.dataset.ring,
+ entryId: node.dataset.entryId,
+ name: node.querySelector(".tile-name").textContent,
+ status: node.querySelector('[data-testid="tile-status"]').textContent,
+ transform: getComputedStyle(node.querySelector(".tile-name")).textTransform,
+ font: getComputedStyle(node.querySelector(".tile-name")).fontFamily,
+ hasImage: Boolean(node.querySelector("img.tile-image")),
+ label: node.querySelector(".tile-surface").getAttribute("aria-label"),
+ })),
+ );
+
+ expect(rings.map((tile) => tile.ring)).toEqual(["playable", "disabled", "scheduled"]);
+ expect(rings[0].status).toBe("Enabled");
+ expect(rings[1].status).toBe("Disabled");
+ expect(rings[2].status).toContain("Scheduled");
+ // Status is described in text as well as colour, and reaches the accessible name.
+ for (const tile of rings) expect(tile.label).toContain(tile.status);
+
+ for (const tile of rings) {
+ expect(tile.hasImage).toBe(true);
+ expect(tile.name, "file extensions are never shown").not.toMatch(/\.(png|jpe?g|webp)$/i);
+ // Uppercase is presentation only; the stored name keeps its case.
+ expect(tile.transform).toBe("uppercase");
+ expect(tile.font).toContain("Roboto");
+ }
+ const stored = await api(request, `/api/playlists/${playlist.id}`);
+ expect(stored.entries[0].asset.displayName).toBe(library["wide-a"].displayName);
+
+ // Stable PlaylistEntry UUIDs are the identity, never positions.
+ expect(rings.map((tile) => tile.entryId)).toEqual(
+ playlist.entries.map((entry) => entry.id),
+ );
+ for (const tile of rings) expect(tile.entryId).toMatch(/^[0-9a-f-]{36}$/);
+});
+
+test("clicking a tile toggles only that playlist entry", async ({ page, request }) => {
+ const library = await ensureFixtures(request);
+ const first = await freshPlaylist(request, "Toggle A", [library["wide-a"].id]);
+ const second = await freshPlaylist(request, "Toggle B", [library["wide-a"].id]);
+
+ await openDashboardOn(page, first.name);
+ const tile = tiles(page).first();
+ await expect(tile).toHaveAttribute("data-ring", "playable");
+ await tile.locator(".tile-surface").click();
+ await expect(tile).toHaveAttribute("data-ring", "disabled");
+
+ // Server-confirmed, and scoped to this playlist entry only.
+ const reloadedFirst = await api(request, `/api/playlists/${first.id}`);
+ expect(reloadedFirst.entries[0].enabled).toBe(false);
+ const reloadedSecond = await api(request, `/api/playlists/${second.id}`);
+ expect(reloadedSecond.entries[0].enabled, "the same asset elsewhere is untouched").toBe(true);
+ expect(
+ (await api(request, `/api/assets/${library["wide-a"].id}`)).status,
+ "the library asset itself is never modified",
+ ).toBe("ready");
+
+ await tile.locator(".tile-surface").click();
+ await expect(tile).toHaveAttribute("data-ring", "playable");
+});
+
+test("dragging a tile reorders the playlist and the order survives a refresh", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Reorder", [
+ library["wide-a"].id,
+ library["wide-b"].id,
+ library["wide-c"].id,
+ library["wide-d"].id,
+ ]);
+ await openDashboardOn(page, playlist.name);
+ await expect(tiles(page)).toHaveCount(4);
+
+ const originalIds = await tiles(page).evaluateAll((nodes) =>
+ nodes.map((node) => node.dataset.entryId),
+ );
+
+ // Drag the first tile onto the last position.
+ const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]');
+ const target = tiles(page).nth(3);
+ const from = await source.boundingBox();
+ const to = await target.boundingBox();
+ await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 25 });
+ await page.mouse.up();
+
+ const expected = [originalIds[1], originalIds[2], originalIds[3], originalIds[0]];
+ await expect
+ .poll(
+ async () => tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)),
+ { timeout: 20_000 },
+ )
+ .toEqual(expected);
+
+ // The backend holds the same order with contiguous zero-based positions.
+ const stored = await api(request, `/api/playlists/${playlist.id}`);
+ expect(stored.entries.map((entry) => entry.id)).toEqual(expected);
+ expect(stored.entries.map((entry) => entry.position)).toEqual([0, 1, 2, 3]);
+
+ // A reload correctly reopens on the active playlist, so navigate back to the
+ // one being edited and confirm the order persisted.
+ await page.reload();
+ await openDashboardOn(page, playlist.name);
+ await expect
+ .poll(async () =>
+ tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)),
+ )
+ .toEqual(expected);
+});
+
+test("a rejected reorder restores the authoritative order without losing the grid", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Reorder fail", [
+ library["wide-a"].id,
+ library["wide-b"].id,
+ library["wide-c"].id,
+ ]);
+ await openDashboardOn(page, playlist.name);
+ const originalIds = await tiles(page).evaluateAll((nodes) =>
+ nodes.map((node) => node.dataset.entryId),
+ );
+
+ await page.route("**/entries/order", (route) =>
+ route.fulfill({
+ status: 409,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "PLAYLIST_ORDER_INCOMPLETE", message: "stale" } }),
+ }),
+ );
+
+ const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]');
+ const target = tiles(page).nth(2);
+ const from = await source.boundingBox();
+ const to = await target.boundingBox();
+ await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 20 });
+ await page.mouse.up();
+
+ await expect(page.getByTestId("toast")).toContainText("Could not save the new order");
+ await expect
+ .poll(async () =>
+ tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)),
+ )
+ .toEqual(originalIds);
+ expect((await api(request, `/api/playlists/${playlist.id}`)).entries.map((e) => e.id)).toEqual(
+ originalIds,
+ );
+});
+
+test("uploads append in selection order and report duplicates without blocking", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Uploads", [library["wide-a"].id]);
+ await openDashboardOn(page, playlist.name);
+
+ // Content identity is the SHA-256 of the bytes, so each run needs genuinely
+ // new pixels; reusing fixture bytes would correctly deduplicate instead.
+ const unique = `${Date.now()}`;
+ const seed = Date.now() % 200;
+ const files = [
+ { name: `alpha-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [seed, 20, 30]) },
+ { name: `beta-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [40, seed, 60]) },
+ { name: `gamma-${unique}.png`, mimeType: "image/png", buffer: makePng(1200, 1200, [70, 80, seed]) },
+ ];
+ await page.getByTestId("file-input").setInputFiles(files);
+
+ // The dashboard stays usable while uploads run.
+ await expect(page.getByTestId("upload-queue")).toBeVisible();
+ await expect(page.getByTestId("playlist-select")).toBeEnabled();
+
+ await expect(tiles(page)).toHaveCount(4, { timeout: 90_000 });
+ const names = await tiles(page).evaluateAll((nodes) =>
+ nodes.map((node) => node.querySelector(".tile-name").textContent),
+ );
+ // Appended at the end, in the order they were selected.
+ expect(names.slice(1)).toEqual([
+ `alpha-${unique}`.toLowerCase(),
+ `beta-${unique}`.toLowerCase(),
+ `gamma-${unique}`.toLowerCase(),
+ ]);
+ await expect(page.locator('[data-testid="upload-item"][data-state="complete"]')).toHaveCount(3);
+
+ // Re-uploading identical bytes is recognised as content already present.
+ await page.getByTestId("file-input").setInputFiles([files[0]]);
+ await expect(
+ page.locator('[data-testid="upload-item"][data-state="already_in_playlist"]'),
+ ).toHaveCount(1, { timeout: 60_000 });
+ await expect(tiles(page), "no duplicate entry is created").toHaveCount(4);
+
+ // A PDF is refused before it reaches the upload pipeline.
+ await page.getByTestId("file-input").setInputFiles([
+ { name: "handout.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") },
+ ]);
+ await expect(page.locator('[data-testid="upload-item"][data-state="failed"]')).toContainText(
+ "PNG, JPEG, and WebP",
+ );
+ await expect(tiles(page)).toHaveCount(4);
+});
+
+test("dropping files onto the dashboard uploads them", async ({ page, request }) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Drop", [library["wide-a"].id]);
+ await openDashboardOn(page, playlist.name);
+
+ const unique = `drop-${Date.now()}`;
+ // Unique bytes so this is a new asset rather than a content duplicate.
+ const buffer = makePng(1920, 1080, [Date.now() % 200, 45, 67]).toString("base64");
+ await page.evaluate(
+ async ([name, base64]) => {
+ const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
+ const file = new File([bytes], `${name}.png`, { type: "image/png" });
+ const transfer = new DataTransfer();
+ transfer.items.add(file);
+ const root = document.querySelector('[data-testid="dashboard"]');
+ root.dispatchEvent(new DragEvent("dragover", { dataTransfer: transfer, bubbles: true }));
+ root.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true }));
+ },
+ [unique, buffer],
+ );
+
+ await expect(tiles(page)).toHaveCount(2, { timeout: 90_000 });
+ await expect(tiles(page).last().locator(".tile-name")).toHaveText(unique);
+});
+
+test("the library reuses existing assets and refuses duplicates within a playlist", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Library reuse", [library["wide-a"].id]);
+ await openDashboardOn(page, playlist.name);
+
+ await page.getByTestId("library-button").click();
+ await expect(page.getByTestId("library-panel")).toBeVisible();
+
+ // An asset already in this playlist cannot be added twice.
+ const already = page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-a"].id}"]`);
+ await expect(already).toHaveAttribute("data-in-playlist", "true");
+ await expect(already.locator("button")).toBeDisabled();
+
+ // A permanent asset must never be presented as expired.
+ await expect(already).not.toContainText("Expired");
+
+ // Add two different assets at once.
+ await page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-b"].id}"] button`).click();
+ await page.locator(`[data-testid="library-item"][data-asset-id="${library.square.id}"] button`).click();
+ await page.getByRole("button", { name: /Add 2 to playlist/ }).click();
+
+ await expect(tiles(page)).toHaveCount(3, { timeout: 30_000 });
+ const stored = await api(request, `/api/playlists/${playlist.id}`);
+ expect(stored.entries.map((entry) => entry.assetId)).toEqual([
+ library["wide-a"].id,
+ library["wide-b"].id,
+ library.square.id,
+ ]);
+
+ // The same asset is free to appear in a different playlist.
+ const other = await freshPlaylist(request, "Library reuse other", [library["wide-a"].id]);
+ expect(other.entries[0].assetId).toBe(library["wide-a"].id);
+});
+
+test("the live preview renders slides with the same presentation as the signage output", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Preview", [library.portrait.id]);
+ await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[0].id}`, {
+ method: "PATCH",
+ data: {
+ backgroundModeOverride: "blurred",
+ backgroundDarkenedOverride: true,
+ },
+ });
+ const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
+ method: "POST",
+ });
+ if (activation.activation === "pending") {
+ await api(request, "/api/playback/next", { method: "POST" });
+ }
+
+ const read = (target) =>
+ target.evaluate(() => {
+ const layer = document.querySelector('.scene[data-visible="true"]');
+ const background = layer.querySelector(".scene-background-image");
+ const foreground = layer.querySelector(".scene-foreground");
+ return {
+ fit: layer.dataset.fit,
+ backgroundMode: layer.dataset.backgroundMode,
+ darkened: layer.dataset.darkened,
+ shadow: layer.dataset.shadow,
+ backgroundDisplay: getComputedStyle(background).display,
+ backgroundFit: getComputedStyle(background).objectFit,
+ backgroundFilter: getComputedStyle(background).filter,
+ foregroundFit: getComputedStyle(foreground).objectFit,
+ foregroundFilter: getComputedStyle(foreground).filter,
+ darkenOpacity: Number(getComputedStyle(layer.querySelector(".scene-darken")).opacity),
+ };
+ });
+
+ await page.goto(`${BASE}/`);
+ await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 });
+ await expect
+ .poll(async () => page.locator('.scene[data-visible="true"]').count(), { timeout: 30_000 })
+ .toBe(1);
+ const preview = await read(page);
+
+ const kiosk = await page.context().newPage();
+ await kiosk.goto(`${BASE}/display`);
+ await expect
+ .poll(async () => kiosk.locator('.scene[data-visible="true"]').count(), { timeout: 45_000 })
+ .toBeGreaterThanOrEqual(1);
+ await kiosk.waitForTimeout(1_500);
+ const signage = await read(kiosk);
+ await kiosk.close();
+
+ // Proving shared semantics rather than a screenshot likeness.
+ expect(preview).toEqual(signage);
+ expect(preview.fit).toBe("contain");
+ expect(preview.backgroundMode).toBe("blurred");
+ expect(preview.backgroundFilter).toBe("blur(20px)");
+ expect(preview.darkenOpacity).toBeCloseTo(0.25, 2);
+});
+
+test("the preview follows mode off and 16:9 fill exactly like the signage output", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Preview modes", [library["wide-a"].id]);
+ const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
+ method: "POST",
+ });
+ if (activation.activation === "pending") {
+ await api(request, "/api/playback/next", { method: "POST" });
+ }
+
+ await page.goto(`${BASE}/`);
+ await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 });
+ await expect
+ .poll(async () => page.locator('.scene[data-visible="true"]').getAttribute("data-fit"), {
+ timeout: 30_000,
+ })
+ .toBe("fill");
+ // A true 16:9 slide fills with no blurred duplicate and no card shadow.
+ const wide = await page.evaluate(() => {
+ const layer = document.querySelector('.scene[data-visible="true"]');
+ return {
+ backgroundMode: layer.dataset.backgroundMode,
+ shadow: layer.dataset.shadow,
+ backgroundDisplay: getComputedStyle(layer.querySelector(".scene-background-image")).display,
+ foregroundFilter: getComputedStyle(layer.querySelector(".scene-foreground")).filter,
+ };
+ });
+ expect(wide).toEqual({
+ backgroundMode: "none",
+ shadow: "false",
+ backgroundDisplay: "none",
+ foregroundFilter: "none",
+ });
+
+ await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
+ await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "true", {
+ timeout: 30_000,
+ });
+ await expect(page.getByTestId("playback-status")).toContainText("Output Off");
+
+ await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
+ await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "false", {
+ timeout: 30_000,
+ });
+});
+
+test("the dashboard reflects a real display coming online and going offline", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const playlist = await freshPlaylist(request, "Display status", [library["wide-a"].id]);
+ const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
+ method: "POST",
+ });
+ if (activation.activation === "pending") {
+ await api(request, "/api/playback/next", { method: "POST" });
+ }
+
+ await page.goto(`${BASE}/`);
+ await expect(page.getByTestId("display-status")).toBeVisible({ timeout: 30_000 });
+
+ // A real kiosk page produces genuine heartbeats.
+ const kiosk = await page.context().newPage();
+ await kiosk.goto(`${BASE}/display`);
+ await expect
+ .poll(async () => page.getByTestId("display-status").getAttribute("data-online"), {
+ timeout: 60_000,
+ })
+ .toBe("true");
+ await expect(page.getByTestId("display-status")).toContainText("Display Online");
+
+ await kiosk.close();
+ // Crossing the server's 45s heartbeat threshold, with no page reload.
+ await expect
+ .poll(async () => page.getByTestId("display-status").getAttribute("data-online"), {
+ timeout: 120_000,
+ })
+ .toBe("false");
+ await expect(page.getByTestId("display-status")).toContainText("Display Offline");
+});
+
+test("an empty playlist explains itself and unplayable slides are distinguished", async ({
+ page,
+ request,
+}) => {
+ const library = await ensureFixtures(request);
+ const empty = await freshPlaylist(request, "Empty", []);
+ await openDashboardOn(page, empty.name);
+ await expect(page.getByTestId("empty-playlist")).toBeVisible();
+ await expect(page.getByTestId("empty-playlist")).toContainText("no slides yet");
+
+ // A playlist with slides that are all currently unplayable is a different
+ // problem and says so.
+ const disabled = await freshPlaylist(request, "All disabled", [library["wide-a"].id]);
+ await api(request, `/api/playlists/${disabled.id}/entries/${disabled.entries[0].id}`, {
+ method: "PATCH",
+ data: { enabled: false },
+ });
+ await openDashboardOn(page, disabled.name);
+ await expect(page.getByTestId("empty-playlist")).toHaveCount(0);
+ await expect(page.getByTestId("playable-summary")).toContainText("No slides are playable");
+});
+
+test("management requires a desktop while the signage route stays available", async ({
+ browser,
+}) => {
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ const page = await context.newPage();
+
+ await page.goto(`${BASE}/`);
+ await expect(page.getByTestId("mobile-splash")).toBeVisible();
+ await expect(page.getByTestId("tile-grid")).toHaveCount(0);
+
+ // The kiosk output must never be gated by the management restriction.
+ await page.goto(`${BASE}/display`);
+ await expect(page.getByTestId("display-root")).toBeVisible();
+ await expect(page.getByTestId("mobile-splash")).toHaveCount(0);
+ await context.close();
+});
diff --git a/client/package.json b/client/package.json
index c5622f6..4b32c3b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,6 +15,10 @@
"test:unit": "node --test \"src/display/__tests__/*.test.js\""
},
"dependencies": {
+ "@dnd-kit/core": "6.3.1",
+ "@dnd-kit/sortable": "10.0.0",
+ "@dnd-kit/utilities": "3.2.2",
+ "@fontsource/roboto": "5.2.5",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"socket.io-client": "^4.8.3"
diff --git a/client/src/App.css b/client/src/App.css
deleted file mode 100644
index f225a9a..0000000
--- a/client/src/App.css
+++ /dev/null
@@ -1,13 +0,0 @@
-.placeholder {
- font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
- margin: 0 auto;
- max-width: 40rem;
- padding: 3rem 1.5rem;
- color: #1f2933;
-}
-
-.placeholder code {
- background: #eef1f5;
- border-radius: 0.25rem;
- padding: 0.1rem 0.35rem;
-}
diff --git a/client/src/App.jsx b/client/src/App.jsx
deleted file mode 100644
index 8970a50..0000000
--- a/client/src/App.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-// Placeholder management route. The dashboard is a later phase; the signage
-// output lives at /display.
-export default function App() {
- return (
-
- Announcements
-
- The signage output is served at /display.
-
-
- );
-}
diff --git a/client/src/dashboard/DashboardApp.jsx b/client/src/dashboard/DashboardApp.jsx
new file mode 100644
index 0000000..b9ff385
--- /dev/null
+++ b/client/src/dashboard/DashboardApp.jsx
@@ -0,0 +1,522 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { io } from "socket.io-client";
+import "@fontsource/roboto/400.css";
+import "@fontsource/roboto/500.css";
+import { api, isAcceptedFile, uploadAsset } from "./api.js";
+import LibraryPanel from "./LibraryPanel.jsx";
+import PlaylistTiles from "./PlaylistTiles.jsx";
+import StatusPanel from "./StatusPanel.jsx";
+import Toasts from "./Toasts.jsx";
+import UploadQueue from "./UploadQueue.jsx";
+import "./dashboard.css";
+
+const DESKTOP_MIN_WIDTH = 769;
+let uploadSequence = 0;
+
+function MobileSplash() {
+ return (
+
+ Announcements
+ Management needs a desktop-sized screen.
+
+ Open this page on a computer to manage playlists and slides. The signage
+ output at /display works on any screen.
+
+
+ );
+}
+
+export default function DashboardApp() {
+ const [wideEnough, setWideEnough] = useState(
+ () => window.innerWidth >= DESKTOP_MIN_WIDTH,
+ );
+ const [playlists, setPlaylists] = useState([]);
+ const [playlist, setPlaylist] = useState(null);
+ const [selectedId, setSelectedId] = useState(null);
+ const [snapshot, setSnapshot] = useState(null);
+ const [displays, setDisplays] = useState([]);
+ const [assets, setAssets] = useState([]);
+ const [libraryOpen, setLibraryOpen] = useState(false);
+ const [uploads, setUploads] = useState([]);
+ const [toasts, setToasts] = useState([]);
+ const [busyEntryIds, setBusyEntryIds] = useState(() => new Set());
+ const [dropping, setDropping] = useState(false);
+ const [activating, setActivating] = useState(false);
+ const fileInputRef = useRef(null);
+ const selectedIdRef = useRef(null);
+ // Async loads check this to discard responses for a playlist the operator
+ // has already navigated away from. Refs must not be written during render.
+ useEffect(() => {
+ selectedIdRef.current = selectedId;
+ }, [selectedId]);
+
+ useEffect(() => {
+ const onResize = () => setWideEnough(window.innerWidth >= DESKTOP_MIN_WIDTH);
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, []);
+
+ const notify = useCallback((message, tone = "info") => {
+ const id = `toast-${++uploadSequence}`;
+ setToasts((current) => [...current, { id, message, tone }]);
+ const timer = setTimeout(
+ () => setToasts((current) => current.filter((toast) => toast.id !== id)),
+ 6_000,
+ );
+ return () => clearTimeout(timer);
+ }, []);
+
+ const loadPlaylist = useCallback(async (playlistId) => {
+ if (!playlistId) {
+ setPlaylist(null);
+ return null;
+ }
+ const detail = await api.getPlaylist(playlistId);
+ // Only apply if the operator is still looking at this playlist.
+ if (selectedIdRef.current === playlistId) setPlaylist(detail);
+ return detail;
+ }, []);
+
+ const refreshPlaylists = useCallback(async () => {
+ const list = await api.listPlaylists();
+ setPlaylists(list);
+ return list;
+ }, []);
+
+ // Initial load: open directly on whatever is actually active.
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const [list, playback, displayList] = await Promise.all([
+ api.listPlaylists(),
+ api.getPlayback(),
+ api.listDisplays().catch(() => []),
+ ]);
+ if (cancelled) return;
+ setPlaylists(list);
+ setSnapshot(playback);
+ setDisplays(displayList);
+ const active = list.find((item) => item.isActive) || list[0] || null;
+ if (active) {
+ setSelectedId(active.id);
+ selectedIdRef.current = active.id;
+ await loadPlaylist(active.id);
+ }
+ } catch (error) {
+ if (!cancelled) notify(error.message, "error");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [loadPlaylist, notify]);
+
+ // Socket handlers are registered before connecting, and torn down on unmount
+ // so a StrictMode remount cannot leave duplicates behind.
+ useEffect(() => {
+ const socket = io({ autoConnect: false });
+ const onPlayback = (next) => setSnapshot(next);
+ const onPlaylists = () => {
+ refreshPlaylists().catch(() => {});
+ if (selectedIdRef.current) loadPlaylist(selectedIdRef.current).catch(() => {});
+ };
+ const onDisplay = () => {
+ api.listDisplays().then(setDisplays).catch(() => {});
+ };
+ socket.on("playback:state", onPlayback);
+ socket.on("playlists:changed", onPlaylists);
+ socket.on("display:status", onDisplay);
+ socket.connect();
+ return () => {
+ socket.off("playback:state", onPlayback);
+ socket.off("playlists:changed", onPlaylists);
+ socket.off("display:status", onDisplay);
+ socket.disconnect();
+ };
+ }, [loadPlaylist, refreshPlaylists]);
+
+ // Heartbeat freshness decides online/offline, so poll the status endpoint at
+ // a low rate rather than trusting the last event forever.
+ useEffect(() => {
+ const timer = setInterval(() => {
+ api.listDisplays().then(setDisplays).catch(() => {});
+ }, 10_000);
+ return () => clearInterval(timer);
+ }, []);
+
+ const activePlaylistId = snapshot?.activePlaylistId ?? null;
+ const pendingPlaylistId = snapshot?.pendingPlaylistId ?? null;
+ const isActive = playlist && playlist.id === activePlaylistId;
+ const isPending = playlist && playlist.id === pendingPlaylistId;
+
+ async function selectPlaylist(playlistId) {
+ // Selecting a playlist only changes what is being edited. It never
+ // activates anything.
+ setSelectedId(playlistId);
+ selectedIdRef.current = playlistId;
+ setPlaylist(null);
+ try {
+ await loadPlaylist(playlistId);
+ } catch (error) {
+ notify(error.message, "error");
+ }
+ }
+
+ async function activateSelected() {
+ if (!playlist) return;
+ setActivating(true);
+ try {
+ const result = await api.activatePlaylist(playlist.id);
+ // The server decides whether this is immediate or queued to a boundary.
+ notify(
+ result.activation === "pending"
+ ? `${playlist.name} will start when the current slide finishes`
+ : `${playlist.name} is now active`,
+ "success",
+ );
+ setSnapshot(result.snapshot ?? (await api.getPlayback()));
+ await refreshPlaylists();
+ } catch (error) {
+ notify(error.message, "error");
+ } finally {
+ setActivating(false);
+ }
+ }
+
+ async function createPlaylist() {
+ const name = `Playlist ${new Date().toLocaleDateString()}`;
+ try {
+ const created = await api.createPlaylist(name);
+ await refreshPlaylists();
+ await selectPlaylist(created.id);
+ notify(`Created ${created.name}`, "success");
+ } catch (error) {
+ notify(error.message, "error");
+ }
+ }
+
+ async function toggleEntry(entry) {
+ if (!playlist) return;
+ setBusyEntryIds((current) => new Set(current).add(entry.id));
+ try {
+ await api.updateEntry(playlist.id, entry.id, { enabled: !entry.enabled });
+ await loadPlaylist(playlist.id);
+ } catch (error) {
+ notify(error.message, "error");
+ await loadPlaylist(playlist.id).catch(() => {});
+ } finally {
+ setBusyEntryIds((current) => {
+ const next = new Set(current);
+ next.delete(entry.id);
+ return next;
+ });
+ }
+ }
+
+ async function reorderEntries(entryIds) {
+ if (!playlist) return;
+ try {
+ const detail = await api.reorderEntries(playlist.id, entryIds);
+ if (selectedIdRef.current === playlist.id) setPlaylist(detail);
+ } catch (error) {
+ notify(`Could not save the new order: ${error.message}`, "error");
+ // Restore whatever the server actually holds.
+ await loadPlaylist(playlist.id).catch(() => {});
+ throw error;
+ }
+ }
+
+ async function addAssets(assetIds) {
+ if (!playlist) return;
+ for (const assetId of assetIds) {
+ try {
+ await api.addEntry(playlist.id, assetId);
+ } catch (error) {
+ notify(
+ error.code === "PLAYLIST_ENTRY_EXISTS"
+ ? "That slide is already in this playlist"
+ : error.message,
+ error.code === "PLAYLIST_ENTRY_EXISTS" ? "info" : "error",
+ );
+ }
+ }
+ await loadPlaylist(playlist.id).catch(() => {});
+ }
+
+ function patchUpload(id, patch) {
+ setUploads((current) =>
+ current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
+ );
+ }
+
+ // Uploads run sequentially so appended entries keep the operator's original
+ // selection order rather than whichever request finished first.
+ async function uploadFiles(fileList) {
+ const files = [...fileList];
+ if (files.length === 0) return;
+ const accepted = files.filter(isAcceptedFile);
+ const rejected = files.filter((file) => !isAcceptedFile(file));
+ for (const file of rejected) {
+ const id = `upload-${++uploadSequence}`;
+ setUploads((current) => [
+ ...current,
+ {
+ id,
+ name: file.name,
+ state: "failed",
+ progress: 0,
+ message: "Only PNG, JPEG, and WebP images are supported",
+ },
+ ]);
+ }
+ const queued = accepted.map((file) => ({
+ file,
+ id: `upload-${++uploadSequence}`,
+ }));
+ setUploads((current) => [
+ ...current,
+ ...queued.map(({ file, id }) => ({
+ id,
+ name: file.name,
+ state: "queued",
+ progress: 0,
+ message: null,
+ })),
+ ]);
+
+ for (const { file, id } of queued) {
+ try {
+ patchUpload(id, { state: "uploading" });
+ const result = await uploadAsset(file, {
+ onProgress: (progress) => patchUpload(id, { progress }),
+ });
+ patchUpload(id, { state: "processing", progress: 1 });
+ const record = result?.asset ? result : result?.results?.[0];
+ const asset = record?.asset;
+ if (!asset) {
+ patchUpload(id, { state: "failed", message: "Upload returned no asset" });
+ continue;
+ }
+ const duplicate = record.duplicate || record.created === false;
+ if (playlist) {
+ try {
+ await api.addEntry(playlist.id, asset.id);
+ patchUpload(id, { state: duplicate ? "duplicate" : "complete" });
+ } catch (error) {
+ if (error.code === "PLAYLIST_ENTRY_EXISTS") {
+ patchUpload(id, {
+ state: "already_in_playlist",
+ message: "This image is already in this playlist",
+ });
+ } else {
+ patchUpload(id, { state: "failed", message: error.message });
+ }
+ }
+ } else {
+ patchUpload(id, { state: duplicate ? "duplicate" : "complete" });
+ }
+ } catch (error) {
+ patchUpload(id, {
+ state: "failed",
+ message:
+ error.code === "ASSET_NAME_CONFLICT"
+ ? "Another slide already uses that name"
+ : error.message,
+ });
+ }
+ }
+
+ await Promise.all([
+ loadPlaylist(selectedIdRef.current).catch(() => {}),
+ api.listAssets().then(setAssets).catch(() => {}),
+ ]);
+ }
+
+ async function openLibrary() {
+ try {
+ setAssets(await api.listAssets());
+ setLibraryOpen(true);
+ } catch (error) {
+ notify(error.message, "error");
+ }
+ }
+
+ const activationState = useMemo(() => {
+ if (!playlist) return null;
+ if (isActive) return "active";
+ if (isPending) return "pending";
+ return "inactive";
+ }, [playlist, isActive, isPending]);
+
+ if (!wideEnough) return ;
+
+ return (
+
{
+ event.preventDefault();
+ setDropping(true);
+ }}
+ onDragLeave={(event) => {
+ if (event.currentTarget === event.target) setDropping(false);
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ setDropping(false);
+ if (event.dataTransfer?.files?.length) void uploadFiles(event.dataTransfer.files);
+ }}
+ >
+
+
+
Announcements
+
+
+ Playlist
+
+ selectPlaylist(event.target.value)}
+ >
+ {playlists.map((item) => (
+
+ {item.name}
+ {item.id === activePlaylistId ? " (active)" : ""}
+
+ ))}
+
+
+ New Playlist
+
+
+
+
+
+ {activationState === "active" ? (
+ ACTIVE
+ ) : activationState === "pending" ? (
+ <>
+ STARTING SOON
+ Waiting for the current slide to finish
+ >
+ ) : activationState === "inactive" ? (
+ <>
+ NOT ACTIVE
+
+ Activate Playlist
+
+ >
+ ) : null}
+
+
+
+ fileInputRef.current?.click()}
+ >
+ Upload Slides
+
+
+ Library
+
+ {
+ void uploadFiles(event.target.files);
+ event.target.value = "";
+ }}
+ />
+
+
+
+
+
+ {playlist ? (
+ <>
+
+ fileInputRef.current?.click()}
+ onLibraryRequest={openLibrary}
+ />
+ >
+ ) : (
+
+ {Array.from({ length: 6 }, (unused, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ setUploads((current) =>
+ current.filter((item) => item.state === "uploading" || item.state === "queued"),
+ )
+ }
+ />
+ {libraryOpen && playlist ? (
+ setLibraryOpen(false)}
+ onAdd={addAssets}
+ />
+ ) : null}
+ setToasts((c) => c.filter((t) => t.id !== id))} />
+ {dropping ? (
+
+ Drop images to add them to this playlist
+
+ ) : null}
+
+ );
+}
+
+// An empty playlist and a playlist whose slides are all currently unplayable
+// are different problems, so they are explained differently.
+function PlayableSummary({ playlist }) {
+ const total = playlist.entries.length;
+ const playable = playlist.entries.filter((entry) => entry.playable).length;
+ if (total === 0) return null;
+ if (playable > 0) {
+ return (
+
+ {playable} of {total} slides playing now
+
+ );
+ }
+ return (
+
+ No slides are playable right now — they are disabled, outside their schedule, or unavailable.
+
+ );
+}
diff --git a/client/src/dashboard/LibraryPanel.jsx b/client/src/dashboard/LibraryPanel.jsx
new file mode 100644
index 0000000..b2d10a6
--- /dev/null
+++ b/client/src/dashboard/LibraryPanel.jsx
@@ -0,0 +1,116 @@
+import { useEffect, useMemo, useState } from "react";
+import { slideLabel } from "./api.js";
+
+function expirationLabel(asset, now) {
+ // A permanent asset has no expiration and must never look stale merely
+ // because it is old.
+ if (asset.expirationAt === null || asset.expirationAt === undefined) return null;
+ const when = new Date(asset.expirationAt);
+ if (asset.expirationAt <= now) return "Expired";
+ return `Expires ${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
+}
+
+export default function LibraryPanel({ assets, playlist, onAdd, onClose, busy }) {
+ const [filter, setFilter] = useState("");
+ const [selected, setSelected] = useState(() => new Set());
+ // Reading the clock during render is impure; it is sampled here instead and
+ // refreshed slowly, which is ample for an expiry label.
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const timer = setInterval(() => setNow(Date.now()), 30_000);
+ return () => clearInterval(timer);
+ }, []);
+
+ const inPlaylist = useMemo(
+ () => new Set(playlist.entries.map((entry) => entry.assetId)),
+ [playlist.entries],
+ );
+
+ const visible = useMemo(() => {
+ const needle = filter.trim().toLowerCase();
+ return assets.filter((asset) => {
+ if (!needle) return true;
+ return slideLabel(asset).toLowerCase().includes(needle);
+ });
+ }, [assets, filter]);
+
+ function toggle(asset, addable) {
+ if (!addable) return;
+ setSelected((current) => {
+ const next = new Set(current);
+ if (next.has(asset.id)) next.delete(asset.id);
+ else next.add(asset.id);
+ return next;
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/dashboard/PlaylistTiles.jsx b/client/src/dashboard/PlaylistTiles.jsx
new file mode 100644
index 0000000..3d4e188
--- /dev/null
+++ b/client/src/dashboard/PlaylistTiles.jsx
@@ -0,0 +1,166 @@
+import { useMemo, useState } from "react";
+import {
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ arrayMove,
+ rectSortingStrategy,
+ sortableKeyboardCoordinates,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { slideLabel } from "./api.js";
+import { entryStatus } from "./entryStatus.js";
+
+function Tile({ entry, onToggle, busy }) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: entry.id,
+ });
+ const status = entryStatus(entry);
+ const label = slideLabel(entry.asset);
+
+ return (
+
+ onToggle(entry)}
+ disabled={busy}
+ aria-pressed={entry.enabled}
+ aria-label={`${label} — ${status.label}. Click to ${
+ entry.enabled ? "disable" : "enable"
+ }.`}
+ title={`${label} — ${status.label}`}
+ >
+
+ {entry.asset?.thumbnail?.url ? (
+
+ ) : (
+
+ )}
+
+ {status.label}
+
+
+
+ ⠿
+
+
+ {label}
+
+
+ );
+}
+
+export default function PlaylistTiles({
+ playlist,
+ onToggleEntry,
+ onReorder,
+ onUploadRequest,
+ onLibraryRequest,
+ busyEntryIds,
+}) {
+ // Local order exists only so a drag feels immediate; the server remains the
+ // source of truth. Authoritative entries are adopted during render rather
+ // than synced in an effect, so no cascading render is needed.
+ const authoritative = useMemo(
+ () => playlist.entries.map((entry) => entry.id),
+ [playlist.entries],
+ );
+ const [order, setOrder] = useState(authoritative);
+ const [adopted, setAdopted] = useState(authoritative);
+ if (adopted !== authoritative) {
+ setAdopted(authoritative);
+ setOrder(authoritative);
+ }
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ const byId = useMemo(
+ () => new Map(playlist.entries.map((entry) => [entry.id, entry])),
+ [playlist.entries],
+ );
+ const entries = order.map((id) => byId.get(id)).filter(Boolean);
+
+ if (playlist.entries.length === 0) {
+ return (
+
+
This playlist has no slides yet
+
Drop images anywhere on this page, or use the actions below.
+
+
+ Upload Slides
+
+
+ Add from Library
+
+
+
+ );
+ }
+
+ async function handleDragEnd(event) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const from = order.indexOf(active.id);
+ const to = order.indexOf(over.id);
+ if (from < 0 || to < 0) return;
+ const next = arrayMove(order, from, to);
+ const previous = order;
+ setOrder(next);
+ try {
+ await onReorder(next);
+ } catch {
+ // The authoritative order wins; the grid snaps back without losing work.
+ setOrder(previous);
+ }
+ }
+
+ return (
+
+
+ {/* Position in the grid is the sequence; no numbers are stamped on tiles. */}
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/src/dashboard/StatusPanel.jsx b/client/src/dashboard/StatusPanel.jsx
new file mode 100644
index 0000000..748b77d
--- /dev/null
+++ b/client/src/dashboard/StatusPanel.jsx
@@ -0,0 +1,64 @@
+import ScenePreview from "../display/ScenePreview.jsx";
+
+const TRANSPORT_LABELS = {
+ playing: "Rotating",
+ paused: "Paused",
+ static: "Static",
+ idle: "No playable slides",
+};
+
+function relativeTime(iso) {
+ if (!iso) return "never";
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes} min ago`;
+ const hours = Math.round(minutes / 60);
+ if (hours < 48) return `${hours} h ago`;
+ return `${Math.round(hours / 24)} d ago`;
+}
+
+export default function StatusPanel({ snapshot, displays }) {
+ // v2 has a single global output; if stray records exist from testing, prefer
+ // any that is online rather than showing an arbitrary one.
+ const display = displays.find((item) => item.status === "online") || displays[0] || null;
+ const online = display?.status === "online";
+ const transport = snapshot?.mode === "off" ? "Output Off" : TRANSPORT_LABELS[snapshot?.transportState] || "—";
+
+ const detail = display
+ ? [
+ `Last seen ${relativeTime(display.lastSeenAt)}`,
+ display.cachedAssetCount !== null && display.cachedAssetCount !== undefined
+ ? `${display.cachedAssetCount} slides cached`
+ : null,
+ display.mostRecentError
+ ? `Error: ${display.mostRecentError.kind || display.mostRecentError}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")
+ : "No display has connected yet";
+
+ return (
+
+
+
+
+
+
+
+ {online ? "Display Online" : "Display Offline"}
+
+ {detail}
+
+
+
+ {transport}
+
+ {snapshot?.activePlaylist?.name ? `Playing ${snapshot.activePlaylist.name}` : "No active playlist"}
+
+
+
+
+ );
+}
diff --git a/client/src/dashboard/Toasts.jsx b/client/src/dashboard/Toasts.jsx
new file mode 100644
index 0000000..e72bc7c
--- /dev/null
+++ b/client/src/dashboard/Toasts.jsx
@@ -0,0 +1,16 @@
+// Application-level messaging. The dashboard never uses alert/confirm/prompt.
+export default function Toasts({ toasts, onDismiss }) {
+ if (toasts.length === 0) return null;
+ return (
+
+ {toasts.map((toast) => (
+
+ {toast.message}
+ onDismiss(toast.id)} aria-label="Dismiss message">
+ ×
+
+
+ ))}
+
+ );
+}
diff --git a/client/src/dashboard/UploadQueue.jsx b/client/src/dashboard/UploadQueue.jsx
new file mode 100644
index 0000000..e0bcae1
--- /dev/null
+++ b/client/src/dashboard/UploadQueue.jsx
@@ -0,0 +1,41 @@
+// Per-file upload state. The dashboard stays usable while these run, and the
+// list distinguishes every state the operator needs to act on.
+const STATE_LABELS = {
+ queued: "Queued",
+ uploading: "Uploading",
+ processing: "Processing",
+ complete: "Added",
+ duplicate: "Already in library",
+ already_in_playlist: "Already in this playlist",
+ failed: "Failed",
+};
+
+export default function UploadQueue({ items, onDismiss }) {
+ if (items.length === 0) return null;
+ return (
+
+
+ Uploads
+
+ Clear finished
+
+
+
+ {items.map((item) => (
+
+ {item.name}
+
+ {STATE_LABELS[item.state] || item.state}
+
+ {item.state === "uploading" ? (
+
+
+
+ ) : null}
+ {item.message ? {item.message} : null}
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/dashboard/api.js b/client/src/dashboard/api.js
new file mode 100644
index 0000000..d39951c
--- /dev/null
+++ b/client/src/dashboard/api.js
@@ -0,0 +1,89 @@
+// A small explicit API layer. Components call these helpers instead of
+// scattering fetch calls, but this is deliberately not a generated SDK or a
+// generic data-repository framework.
+async function request(path, { method = "GET", body, signal } = {}) {
+ const response = await fetch(path, {
+ method,
+ signal,
+ ...(body === undefined
+ ? {}
+ : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
+ });
+ const text = await response.text();
+ const parsed = text ? JSON.parse(text) : null;
+ if (!response.ok) {
+ const error = new Error(parsed?.error?.message || `Request failed (${response.status})`);
+ error.code = parsed?.error?.code || "REQUEST_FAILED";
+ error.status = response.status;
+ error.details = parsed?.error?.details;
+ throw error;
+ }
+ return parsed;
+}
+
+export const api = {
+ listPlaylists: () => request("/api/playlists").then((body) => body.playlists),
+ getPlaylist: (playlistId) => request(`/api/playlists/${playlistId}`),
+ createPlaylist: (name) => request("/api/playlists", { method: "POST", body: { name } }),
+ activatePlaylist: (playlistId) =>
+ request(`/api/playlists/${playlistId}/activate`, { method: "POST" }),
+ addEntry: (playlistId, assetId) =>
+ request(`/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId } }),
+ updateEntry: (playlistId, entryId, patch) =>
+ request(`/api/playlists/${playlistId}/entries/${entryId}`, { method: "PATCH", body: patch }),
+ reorderEntries: (playlistId, entryIds) =>
+ request(`/api/playlists/${playlistId}/entries/order`, {
+ method: "PUT",
+ body: { entryIds },
+ }),
+ listAssets: () => request("/api/assets").then((body) => body.assets),
+ listDisplays: () => request("/api/displays").then((body) => body.displays),
+ getPlayback: () => request("/api/playback"),
+};
+
+// Uploads use the existing Phase 2 multipart pipeline. The browser never talks
+// to object storage directly, and progress needs XHR rather than fetch.
+export function uploadAsset(file, { onProgress } = {}) {
+ return new Promise((resolve, reject) => {
+ const form = new FormData();
+ form.append("files", file, file.name);
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", "/api/assets");
+ xhr.upload.addEventListener("progress", (event) => {
+ if (event.lengthComputable) onProgress?.(event.loaded / event.total);
+ });
+ xhr.addEventListener("load", () => {
+ let parsed;
+ try {
+ parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
+ } catch {
+ parsed = null;
+ }
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(parsed);
+ return;
+ }
+ const error = new Error(parsed?.error?.message || `Upload failed (${xhr.status})`);
+ error.code = parsed?.error?.code || "UPLOAD_FAILED";
+ error.status = xhr.status;
+ reject(error);
+ });
+ xhr.addEventListener("error", () => reject(new Error("Upload failed")));
+ xhr.send(form);
+ });
+}
+
+export const ACCEPTED_UPLOAD_TYPES = ["image/png", "image/jpeg", "image/webp"];
+
+export function isAcceptedFile(file) {
+ if (ACCEPTED_UPLOAD_TYPES.includes(file.type)) return true;
+ // Some browsers report an empty type; fall back to the extension.
+ return /\.(png|jpe?g|webp)$/i.test(file.name || "");
+}
+
+// Stored display names keep their original case; presentation uppercases them
+// with CSS. File extensions are never part of the visible slide name.
+export function slideLabel(asset) {
+ const name = asset?.displayName || asset?.originalFilename || "Untitled";
+ return name.replace(/\.(png|jpe?g|webp|gif|bmp|tiff?)$/i, "");
+}
diff --git a/client/src/dashboard/dashboard.css b/client/src/dashboard/dashboard.css
new file mode 100644
index 0000000..9ebbbe9
--- /dev/null
+++ b/client/src/dashboard/dashboard.css
@@ -0,0 +1,441 @@
+/* Desktop management surface. Slide imagery dominates; chrome stays quiet.
+ None of this is applied to /display, which keeps its own scoped styling. */
+.dashboard {
+ --surface: #0f1419;
+ --panel: #161d25;
+ --line: #232d38;
+ --text: #eef2f6;
+ --muted: #93a1b0;
+ --accent: #3b82f6;
+ --ok: #22c55e;
+ --warn: #f59e0b;
+ --bad: #ef4444;
+ min-height: 100vh;
+ background: var(--surface);
+ color: var(--text);
+ font-family: "Roboto", system-ui, -apple-system, "Segoe UI", sans-serif;
+ display: flex;
+ flex-direction: column;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+.dashboard-header {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ padding: 0.9rem 1.5rem;
+ border-bottom: 1px solid var(--line);
+ background: var(--panel);
+}
+
+.header-identity {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.product {
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted);
+ font-size: 0.8rem;
+}
+
+.playlist-choice {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.playlist-choice select {
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--line);
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ font: inherit;
+ min-width: 14rem;
+}
+
+.header-activation {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex: 1;
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.badge {
+ font-size: 0.72rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ padding: 0.3rem 0.6rem;
+ border-radius: 999px;
+ border: 1px solid currentColor;
+}
+
+.badge.active { color: var(--ok); }
+.badge.pending { color: var(--warn); }
+.badge.inactive { color: var(--muted); }
+
+.button {
+ font: inherit;
+ border-radius: 0.5rem;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ color: var(--text);
+ padding: 0.5rem 0.9rem;
+ cursor: pointer;
+}
+
+.button:hover { border-color: var(--muted); }
+.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.button[disabled] { opacity: 0.5; cursor: default; }
+.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.button.subtle { background: transparent; color: var(--muted); }
+
+.dashboard-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 22rem;
+ gap: 1.5rem;
+ padding: 1.5rem;
+ flex: 1;
+ align-items: start;
+}
+
+.playlist-heading h1 {
+ margin: 0;
+ font-size: 1.35rem;
+ font-weight: 500;
+}
+
+.playlist-heading .muted,
+.playlist-heading .warning {
+ margin: 0.25rem 0 1rem;
+ font-size: 0.85rem;
+}
+
+.muted { color: var(--muted); }
+.warning { color: var(--warn); }
+
+/* Position in the grid is the sequence; tiles carry no ordinal numbers. */
+.tile-grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+ gap: 1.1rem;
+}
+
+.tile {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.tile[data-dragging="true"] {
+ z-index: 5;
+ filter: drop-shadow(0 1rem 1.5rem rgba(0, 0, 0, 0.55));
+}
+
+.tile-surface {
+ position: relative;
+ padding: 0;
+ border: none;
+ background: #000;
+ border-radius: 0.7rem;
+ overflow: hidden;
+ aspect-ratio: 16 / 9;
+ cursor: pointer;
+ display: block;
+ width: 100%;
+}
+
+.tile-surface:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+
+.tile-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.tile[data-enabled="false"] .tile-image { opacity: 0.4; filter: grayscale(0.7); }
+.tile-image-missing { background: #22303c; }
+
+/* The ring reads clearly without covering the artwork. */
+.tile-ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 0.7rem;
+ border: 3px solid transparent;
+ pointer-events: none;
+}
+
+.tile-ring[data-ring="playable"] { border-color: var(--ok); }
+.tile-ring[data-ring="disabled"] { border-color: #6b7785; }
+.tile-ring[data-ring="scheduled"] { border-color: var(--warn); }
+.tile-ring[data-ring="error"] { border-color: var(--bad); }
+
+/* Status is always available as text, never colour alone. */
+.tile-status {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 0.35rem 0.5rem;
+ font-size: 0.72rem;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
+ color: #fff;
+ text-align: left;
+}
+
+.tile-drag {
+ position: absolute;
+ top: 0.4rem;
+ right: 0.4rem;
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.4rem;
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+ cursor: grab;
+ font-size: 0.9rem;
+ line-height: 1.2;
+}
+
+.tile-drag:focus-visible { outline: 2px solid var(--accent); }
+
+.tile-name {
+ font-size: 0.82rem;
+ font-weight: 500;
+ letter-spacing: 0.03em;
+ /* Presentation only: the stored display name keeps its original case. */
+ text-transform: uppercase;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.empty-state {
+ border: 2px dashed var(--line);
+ border-radius: 1rem;
+ padding: 4rem 2rem;
+ text-align: center;
+}
+
+.empty-state h2 { margin: 0 0 0.5rem; font-weight: 500; }
+.empty-state p { color: var(--muted); margin: 0 0 1.5rem; }
+.empty-actions { display: flex; gap: 0.75rem; justify-content: center; }
+
+.skeleton-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+ gap: 1.1rem;
+}
+
+.skeleton-tile {
+ aspect-ratio: 16 / 9;
+ border-radius: 0.7rem;
+ background: var(--panel);
+}
+
+.status-panel {
+ position: sticky;
+ top: 1.5rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 0.9rem;
+ overflow: hidden;
+}
+
+.preview-frame { background: #000; }
+.status-rows { padding: 0.9rem 1rem; display: grid; gap: 0.75rem; }
+
+.status-row {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 0.5rem 0.6rem;
+}
+
+.status-dot {
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ background: var(--bad);
+}
+
+.status-dot[data-online="true"] { background: var(--ok); }
+.status-label { font-weight: 500; font-size: 0.9rem; }
+.status-detail {
+ grid-column: 1 / -1;
+ color: var(--muted);
+ font-size: 0.78rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.upload-queue {
+ position: fixed;
+ right: 1.25rem;
+ bottom: 1.25rem;
+ width: 22rem;
+ max-height: 40vh;
+ overflow: auto;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 0.8rem;
+ padding: 0.75rem 0.9rem;
+}
+
+.upload-queue header { display: flex; justify-content: space-between; align-items: center; }
+.upload-queue h2 { margin: 0; font-size: 0.85rem; font-weight: 500; }
+.upload-queue ul { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.5rem; }
+.upload-queue li { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem 0.5rem; font-size: 0.78rem; }
+.upload-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.upload-state { color: var(--muted); }
+.upload-state[data-state="complete"] { color: var(--ok); }
+.upload-state[data-state="failed"] { color: var(--bad); }
+.upload-state[data-state="duplicate"],
+.upload-state[data-state="already_in_playlist"] { color: var(--warn); }
+.upload-message { grid-column: 1 / -1; color: var(--muted); }
+.upload-bar { grid-column: 1 / -1; height: 3px; background: var(--line); border-radius: 2px; }
+.upload-bar span { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
+
+.library-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 30rem;
+ background: var(--panel);
+ border-left: 1px solid var(--line);
+ padding: 1rem 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ z-index: 20;
+}
+
+.library-panel header { display: flex; justify-content: space-between; align-items: center; }
+.library-panel h2 { margin: 0; font-size: 1rem; font-weight: 500; }
+
+.library-search {
+ font: inherit;
+ padding: 0.5rem 0.7rem;
+ border-radius: 0.5rem;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ color: var(--text);
+}
+
+.library-grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
+ gap: 0.75rem;
+ overflow: auto;
+ flex: 1;
+}
+
+.library-grid button {
+ width: 100%;
+ padding: 0;
+ border: 2px solid transparent;
+ border-radius: 0.6rem;
+ background: var(--surface);
+ color: var(--text);
+ cursor: pointer;
+ overflow: hidden;
+ display: grid;
+ gap: 0.25rem;
+}
+
+.library-grid li[data-selected="true"] button { border-color: var(--accent); }
+.library-grid button[disabled] { opacity: 0.55; cursor: default; }
+.library-grid img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; }
+.library-image-missing { width: 100%; aspect-ratio: 16 / 9; background: #22303c; display: block; }
+.library-name {
+ font-size: 0.72rem;
+ padding: 0 0.4rem;
+ text-transform: uppercase;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.library-tag { font-size: 0.65rem; color: var(--muted); padding: 0 0.4rem 0.4rem; }
+.library-tag.warn { color: var(--bad); }
+.library-tag[data-expired="true"] { color: var(--bad); }
+
+.toasts {
+ position: fixed;
+ left: 50%;
+ bottom: 1.5rem;
+ transform: translateX(-50%);
+ display: grid;
+ gap: 0.5rem;
+ z-index: 30;
+}
+
+.toast {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-left: 3px solid var(--muted);
+ border-radius: 0.5rem;
+ padding: 0.6rem 0.9rem;
+ font-size: 0.85rem;
+}
+
+.toast[data-tone="error"] { border-left-color: var(--bad); }
+.toast[data-tone="success"] { border-left-color: var(--ok); }
+.toast button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
+
+.drop-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 20, 25, 0.75);
+ border: 3px dashed var(--accent);
+ display: grid;
+ place-items: center;
+ font-size: 1.1rem;
+ z-index: 40;
+ pointer-events: none;
+}
+
+.mobile-splash {
+ min-height: 100vh;
+ display: grid;
+ place-content: center;
+ gap: 0.75rem;
+ padding: 2rem;
+ text-align: center;
+ background: #0f1419;
+ color: #eef2f6;
+ font-family: "Roboto", system-ui, sans-serif;
+}
+
+.mobile-splash h1 { margin: 0; font-weight: 500; }
+.mobile-splash .muted { color: #93a1b0; font-size: 0.9rem; max-width: 28rem; }
diff --git a/client/src/dashboard/entryStatus.js b/client/src/dashboard/entryStatus.js
new file mode 100644
index 0000000..15bea09
--- /dev/null
+++ b/client/src/dashboard/entryStatus.js
@@ -0,0 +1,38 @@
+// Ring semantics for a playlist entry. Status is never conveyed by colour
+// alone: every tile also carries this text for hover, focus, and assistive
+// technology.
+function formatInstant(value) {
+ try {
+ return new Date(value).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ } catch {
+ return "a scheduled time";
+ }
+}
+
+export function entryStatus(entry) {
+ if (!entry.enabled) return { ring: "disabled", label: "Disabled" };
+ const reason = entry.notPlayableReason;
+ if (reason === "asset_not_ready" || reason === "asset_expired") {
+ return {
+ ring: "error",
+ label: reason === "asset_expired" ? "Asset expired" : "Asset unavailable",
+ };
+ }
+ if (reason === "scheduled_future") {
+ return {
+ ring: "scheduled",
+ label: entry.scheduleStartAt
+ ? `Scheduled — available ${formatInstant(entry.scheduleStartAt)}`
+ : "Scheduled — not yet available",
+ };
+ }
+ if (reason === "scheduled_ended") {
+ return { ring: "scheduled", label: "Scheduled period ended" };
+ }
+ return { ring: "playable", label: "Enabled" };
+}
diff --git a/client/src/display/ScenePreview.jsx b/client/src/display/ScenePreview.jsx
new file mode 100644
index 0000000..532f0bd
--- /dev/null
+++ b/client/src/display/ScenePreview.jsx
@@ -0,0 +1,76 @@
+import { resolveScenePresentation } from "./presentation.js";
+import "./scene.css";
+
+// The dashboard's live preview and the kiosk renderer must never drift apart.
+// This component reuses the same presentation resolver and the same scene CSS
+// the signage output uses; only the container is different.
+export default function ScenePreview({ snapshot, className = "" }) {
+ const target = snapshot?.staticTarget || snapshot?.normalTarget || null;
+ const mediaUrl = target?.asset?.media?.url ?? null;
+ const presentation = target ? resolveScenePresentation(target) : null;
+ const modeOff = snapshot?.mode === "off";
+
+ return (
+
+ {presentation && mediaUrl ? (
+
+
+
+
+
+
+
+ ) : null}
+ {/* Mode off is the same topmost black gate the kiosk applies. */}
+
+
+ );
+}
diff --git a/client/src/display/display.css b/client/src/display/display.css
index 4ec5c89..ed1c6bf 100644
--- a/client/src/display/display.css
+++ b/client/src/display/display.css
@@ -1,3 +1,5 @@
+@import "./scene.css";
+
/* Signage-only styling. These fullscreen constraints are scoped to the display
route so the future management dashboard is unaffected. */
body[data-display-route="true"] {
@@ -21,53 +23,6 @@ body[data-display-route="true"] {
outline: none;
}
-/* Two permanent layers. Only opacity and content change; they are never
- recreated, and an outgoing layer is never faded out. */
-.scene {
- position: absolute;
- inset: 0;
- opacity: 0;
- background: #000;
- transition-property: opacity;
- transition-timing-function: linear;
- transition-duration: 0ms;
- will-change: opacity;
-}
-
-.scene-background {
- position: absolute;
- inset: 0;
- background-color: #000;
- overflow: hidden;
-}
-
-/* Overscanned so the blur never reveals a transparent or black edge. */
-.scene-background-image {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
- object-position: center;
- transform: scale(1.08);
- display: none;
-}
-
-.scene-darken {
- position: absolute;
- inset: 0;
- background: #000;
- opacity: 0;
-}
-
-.scene-foreground {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- object-position: center;
-}
-
/* Topmost black gate for mode off. It covers the output instantly while the
renderer keeps tracking the authoritative timeline underneath. */
.output-gate {
diff --git a/client/src/display/scene.css b/client/src/display/scene.css
new file mode 100644
index 0000000..e819a4c
--- /dev/null
+++ b/client/src/display/scene.css
@@ -0,0 +1,62 @@
+/* Shared scene presentation.
+ Both the kiosk renderer and the dashboard's live preview import this, so a
+ slide cannot look one way on the signage output and another way in
+ management. */
+/* Two permanent layers. Only opacity and content change; they are never
+ recreated, and an outgoing layer is never faded out. */
+.scene {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ background: #000;
+ transition-property: opacity;
+ transition-timing-function: linear;
+ transition-duration: 0ms;
+ will-change: opacity;
+}
+
+.scene-background {
+ position: absolute;
+ inset: 0;
+ background-color: #000;
+ overflow: hidden;
+}
+
+/* Overscanned so the blur never reveals a transparent or black edge. */
+.scene-background-image {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ transform: scale(1.08);
+ display: none;
+}
+
+.scene-darken {
+ position: absolute;
+ inset: 0;
+ background: #000;
+ opacity: 0;
+}
+
+.scene-foreground {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-position: center;
+}
+
+
+/* A preview renders the same scene inside a small box. Blur is expressed in
+ pixels, so it is scaled down with the container to keep the preview
+ visually representative of the full-size output. */
+.scene-preview {
+ position: relative;
+ overflow: hidden;
+ background: #000;
+ aspect-ratio: 16 / 9;
+ width: 100%;
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
index c3cb3d2..9e838cf 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -1,7 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
-import App from "./App.jsx";
+import DashboardApp from "./dashboard/DashboardApp.jsx";
import DisplayApp from "./display/DisplayApp.jsx";
const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
@@ -18,5 +18,5 @@ if (isDisplayRoute && "serviceWorker" in navigator) {
}
createRoot(document.getElementById("root")).render(
- {isDisplayRoute ? : } ,
+ {isDisplayRoute ? : } ,
);
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 6b2780e..10957a7 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -13,7 +13,13 @@ services:
api:
condition: service_healthy
networks:
- - app
+ app:
+ # Only the web service is reachable from the shared reverse-proxy
+ # network. Caddy resolves it as announcements-web:80; the API and object
+ # storage stay private on the Announcements networks.
+ tabernet:
+ aliases:
+ - announcements-web
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
@@ -110,6 +116,8 @@ networks:
app:
storage:
internal: true
+ tabernet:
+ external: true
volumes:
app-data:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3051183..bb20c3f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,6 +10,18 @@ importers:
client:
dependencies:
+ '@dnd-kit/core':
+ specifier: 6.3.1
+ version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@dnd-kit/sortable':
+ specifier: 10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
+ '@dnd-kit/utilities':
+ specifier: 3.2.2
+ version: 3.2.2(react@19.2.8)
+ '@fontsource/roboto':
+ specifier: 5.2.5
+ version: 5.2.5
react:
specifier: ^19.2.6
version: 19.2.8
@@ -223,6 +235,28 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
@@ -265,6 +299,9 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ '@fontsource/roboto@5.2.5':
+ resolution: {integrity: sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==}
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1918,6 +1955,31 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@dnd-kit/accessibility@3.1.1(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.8)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.8)
+ react: 19.2.8
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+ tslib: 2.8.1
+
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
@@ -1957,6 +2019,8 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
+ '@fontsource/roboto@5.2.5': {}
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0