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 SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
S3 credentials before starting the stack. 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 ## Library assets
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files` `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 decode failures arrive with the Phase 5 renderer, which will report them through
this same field and event. 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 ## Signage display
`/display` is the signage output and `/` is reserved for the future management `/display` is the signage output and `/` is reserved for the future management
+589
View File
@@ -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();
});
+4
View File
@@ -15,6 +15,10 @@
"test:unit": "node --test \"src/display/__tests__/*.test.js\"" "test:unit": "node --test \"src/display/__tests__/*.test.js\""
}, },
"dependencies": { "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": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3"
-13
View File
@@ -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;
}
-12
View File
@@ -1,12 +0,0 @@
// Placeholder management route. The dashboard is a later phase; the signage
// output lives at /display.
export default function App() {
return (
<main className="placeholder">
<h1>Announcements</h1>
<p>
The signage output is served at <code>/display</code>.
</p>
</main>
);
}
+522
View File
@@ -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 (
<main className="mobile-splash" data-testid="mobile-splash">
<h1>Announcements</h1>
<p>Management needs a desktop-sized screen.</p>
<p className="muted">
Open this page on a computer to manage playlists and slides. The signage
output at <code>/display</code> works on any screen.
</p>
</main>
);
}
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 <MobileSplash />;
return (
<div
className="dashboard"
data-testid="dashboard"
data-dropping={dropping ? "true" : "false"}
onDragOver={(event) => {
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);
}}
>
<header className="dashboard-header">
<div className="header-identity">
<span className="product">Announcements</span>
<div className="playlist-choice">
<label className="visually-hidden" htmlFor="playlist-select">
Playlist
</label>
<select
id="playlist-select"
data-testid="playlist-select"
value={selectedId ?? ""}
onChange={(event) => selectPlaylist(event.target.value)}
>
{playlists.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{item.id === activePlaylistId ? " (active)" : ""}
</option>
))}
</select>
<button type="button" className="button subtle" onClick={createPlaylist}>
New Playlist
</button>
</div>
</div>
<div className="header-activation" data-testid="activation-state" data-state={activationState}>
{activationState === "active" ? (
<span className="badge active">ACTIVE</span>
) : activationState === "pending" ? (
<>
<span className="badge pending">STARTING SOON</span>
<span className="muted">Waiting for the current slide to finish</span>
</>
) : activationState === "inactive" ? (
<>
<span className="badge inactive">NOT ACTIVE</span>
<button
type="button"
className="button primary"
data-testid="activate-button"
onClick={activateSelected}
disabled={activating}
>
Activate Playlist
</button>
</>
) : null}
</div>
<div className="header-actions">
<button
type="button"
className="button primary"
data-testid="upload-button"
onClick={() => fileInputRef.current?.click()}
>
Upload Slides
</button>
<button type="button" className="button" data-testid="library-button" onClick={openLibrary}>
Library
</button>
<input
ref={fileInputRef}
data-testid="file-input"
type="file"
multiple
accept="image/png,image/jpeg,image/webp"
className="visually-hidden"
onChange={(event) => {
void uploadFiles(event.target.files);
event.target.value = "";
}}
/>
</div>
</header>
<div className="dashboard-body">
<main className="playlist-area">
{playlist ? (
<>
<div className="playlist-heading">
<h1>{playlist.name}</h1>
<PlayableSummary playlist={playlist} />
</div>
<PlaylistTiles
playlist={playlist}
busyEntryIds={busyEntryIds}
onToggleEntry={toggleEntry}
onReorder={reorderEntries}
onUploadRequest={() => fileInputRef.current?.click()}
onLibraryRequest={openLibrary}
/>
</>
) : (
<div className="skeleton-grid" data-testid="loading-skeleton" aria-hidden="true">
{Array.from({ length: 6 }, (unused, index) => (
<div key={index} className="skeleton-tile" />
))}
</div>
)}
</main>
<StatusPanel snapshot={snapshot} displays={displays} />
</div>
<UploadQueue
items={uploads}
onDismiss={() =>
setUploads((current) =>
current.filter((item) => item.state === "uploading" || item.state === "queued"),
)
}
/>
{libraryOpen && playlist ? (
<LibraryPanel
assets={assets}
playlist={playlist}
busy={false}
onClose={() => setLibraryOpen(false)}
onAdd={addAssets}
/>
) : null}
<Toasts toasts={toasts} onDismiss={(id) => setToasts((c) => c.filter((t) => t.id !== id))} />
{dropping ? (
<div className="drop-overlay" data-testid="drop-overlay">
Drop images to add them to this playlist
</div>
) : null}
</div>
);
}
// 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 (
<p className="muted" data-testid="playable-summary">
{playable} of {total} slides playing now
</p>
);
}
return (
<p className="warning" data-testid="playable-summary">
No slides are playable right now — they are disabled, outside their schedule, or unavailable.
</p>
);
}
+116
View File
@@ -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 (
<aside className="library-panel" data-testid="library-panel" aria-label="Library">
<header>
<h2>Library</h2>
<button type="button" className="button subtle" onClick={onClose} aria-label="Close library">
Close
</button>
</header>
<input
type="search"
className="library-search"
placeholder="Search slides"
aria-label="Search library"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<ul className="library-grid">
{visible.map((asset) => {
const already = inPlaylist.has(asset.id);
const expired = asset.expirationAt !== null && asset.expirationAt <= now;
const ready = asset.status === "ready";
const addable = !already && !expired && ready;
const expiration = expirationLabel(asset, now);
return (
<li
key={asset.id}
data-testid="library-item"
data-asset-id={asset.id}
data-addable={addable ? "true" : "false"}
data-in-playlist={already ? "true" : "false"}
data-selected={selected.has(asset.id) ? "true" : "false"}
>
<button
type="button"
onClick={() => toggle(asset, addable)}
disabled={!addable}
aria-pressed={selected.has(asset.id)}
aria-label={`${slideLabel(asset)}${already ? " — already in this playlist" : ""}`}
>
{asset.thumbnail?.url ? (
<img src={asset.thumbnail.url} alt="" loading="lazy" />
) : (
<span className="library-image-missing" aria-hidden="true" />
)}
<span className="library-name">{slideLabel(asset)}</span>
{already ? <span className="library-tag">In this playlist</span> : null}
{!ready ? <span className="library-tag warn">Unavailable</span> : null}
{expiration ? (
<span className="library-tag" data-expired={expired ? "true" : "false"}>
{expiration}
</span>
) : null}
</button>
</li>
);
})}
</ul>
<footer>
<button
type="button"
className="button primary"
disabled={selected.size === 0 || busy}
onClick={() => onAdd([...selected]).then(() => setSelected(new Set()))}
>
Add {selected.size > 0 ? `${selected.size} ` : ""}to playlist
</button>
</footer>
</aside>
);
}
+166
View File
@@ -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 (
<li
ref={setNodeRef}
className="tile"
data-testid="tile"
data-entry-id={entry.id}
data-asset-id={entry.assetId}
data-ring={status.ring}
data-enabled={entry.enabled ? "true" : "false"}
data-dragging={isDragging ? "true" : "false"}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<button
type="button"
className="tile-surface"
// Primary click toggles this playlist entry only. The underlying
// library asset, and its use in other playlists, is untouched.
onClick={() => onToggle(entry)}
disabled={busy}
aria-pressed={entry.enabled}
aria-label={`${label} — ${status.label}. Click to ${
entry.enabled ? "disable" : "enable"
}.`}
title={`${label} — ${status.label}`}
>
<span className="tile-ring" data-ring={status.ring} aria-hidden="true" />
{entry.asset?.thumbnail?.url ? (
<img className="tile-image" src={entry.asset.thumbnail.url} alt="" loading="lazy" />
) : (
<span className="tile-image tile-image-missing" aria-hidden="true" />
)}
<span className="tile-status" data-testid="tile-status">
{status.label}
</span>
</button>
<span
className="tile-drag"
{...attributes}
{...listeners}
data-testid="tile-drag"
role="button"
tabIndex={0}
aria-label={`Reorder ${label}`}
title="Drag to reorder"
>
<span aria-hidden="true">⠿</span>
</span>
<span className="tile-name" title={label}>
{label}
</span>
</li>
);
}
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 (
<div className="empty-state" data-testid="empty-playlist">
<h2>This playlist has no slides yet</h2>
<p>Drop images anywhere on this page, or use the actions below.</p>
<div className="empty-actions">
<button type="button" className="button primary" onClick={onUploadRequest}>
Upload Slides
</button>
<button type="button" className="button" onClick={onLibraryRequest}>
Add from Library
</button>
</div>
</div>
);
}
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 (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={order} strategy={rectSortingStrategy}>
{/* Position in the grid is the sequence; no numbers are stamped on tiles. */}
<ul className="tile-grid" data-testid="tile-grid">
{entries.map((entry) => (
<Tile
key={entry.id}
entry={entry}
onToggle={onToggleEntry}
busy={busyEntryIds.has(entry.id)}
/>
))}
</ul>
</SortableContext>
</DndContext>
);
}
+64
View File
@@ -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 (
<section className="status-panel" aria-label="Output status">
<div className="preview-frame">
<ScenePreview snapshot={snapshot} />
</div>
<div className="status-rows">
<div className="status-row" data-testid="display-status" data-online={online ? "true" : "false"}>
<span className="status-dot" data-online={online ? "true" : "false"} aria-hidden="true" />
<span className="status-label">{online ? "Display Online" : "Display Offline"}</span>
<span className="status-detail" title={detail}>
{detail}
</span>
</div>
<div className="status-row" data-testid="playback-status">
<span className="status-label">{transport}</span>
<span className="status-detail">
{snapshot?.activePlaylist?.name ? `Playing ${snapshot.activePlaylist.name}` : "No active playlist"}
</span>
</div>
</div>
</section>
);
}
+16
View File
@@ -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 (
<div className="toasts" role="status" aria-live="polite" data-testid="toasts">
{toasts.map((toast) => (
<div key={toast.id} className="toast" data-tone={toast.tone} data-testid="toast">
<span>{toast.message}</span>
<button type="button" onClick={() => onDismiss(toast.id)} aria-label="Dismiss message">
×
</button>
</div>
))}
</div>
);
}
+41
View File
@@ -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 (
<section className="upload-queue" data-testid="upload-queue" aria-live="polite">
<header>
<h2>Uploads</h2>
<button type="button" className="button subtle" onClick={onDismiss}>
Clear finished
</button>
</header>
<ul>
{items.map((item) => (
<li key={item.id} data-testid="upload-item" data-state={item.state}>
<span className="upload-name">{item.name}</span>
<span className="upload-state" data-state={item.state}>
{STATE_LABELS[item.state] || item.state}
</span>
{item.state === "uploading" ? (
<span className="upload-bar" aria-hidden="true">
<span style={{ width: `${Math.round(item.progress * 100)}%` }} />
</span>
) : null}
{item.message ? <span className="upload-message">{item.message}</span> : null}
</li>
))}
</ul>
</section>
);
}
+89
View File
@@ -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, "");
}
+441
View File
@@ -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; }
+38
View File
@@ -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" };
}
+76
View File
@@ -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 (
<div
className={`scene-preview ${className}`.trim()}
data-testid="scene-preview"
data-mode={snapshot?.mode ?? "unknown"}
data-target={target ? (snapshot?.staticTarget ? "static" : "normal") : "black"}
>
{presentation && mediaUrl ? (
<div
className="scene"
data-visible="true"
data-preview-layer="true"
data-fit={presentation.fit}
data-background-mode={presentation.backgroundMode}
data-darkened={presentation.darkened ? "true" : "false"}
data-shadow={presentation.shadow ? "true" : "false"}
style={{ opacity: 1 }}
>
<div
className="scene-background"
style={{
backgroundColor:
presentation.backgroundMode === "solid"
? presentation.backgroundColor
: "#000000",
}}
>
<img
className="scene-background-image"
alt=""
aria-hidden="true"
src={presentation.backgroundImage ? mediaUrl : undefined}
style={{
display: presentation.backgroundImage ? "block" : "none",
filter: presentation.backgroundImage
? `blur(${presentation.blurPx}px)`
: "none",
}}
/>
</div>
<div className="scene-darken" style={{ opacity: presentation.darkenOpacity }} />
<img
className="scene-foreground"
alt=""
src={mediaUrl}
style={{
objectFit: presentation.fit === "fill" ? "cover" : "contain",
filter: presentation.shadow
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
: "none",
}}
/>
</div>
) : null}
{/* Mode off is the same topmost black gate the kiosk applies. */}
<div
className="output-gate"
data-testid="preview-gate"
data-active={modeOff ? "true" : "false"}
style={{ opacity: modeOff ? 1 : 0 }}
/>
</div>
);
}
+2 -47
View File
@@ -1,3 +1,5 @@
@import "./scene.css";
/* Signage-only styling. These fullscreen constraints are scoped to the display /* Signage-only styling. These fullscreen constraints are scoped to the display
route so the future management dashboard is unaffected. */ route so the future management dashboard is unaffected. */
body[data-display-route="true"] { body[data-display-route="true"] {
@@ -21,53 +23,6 @@ body[data-display-route="true"] {
outline: none; 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 /* Topmost black gate for mode off. It covers the output instantly while the
renderer keeps tracking the authoritative timeline underneath. */ renderer keeps tracking the authoritative timeline underneath. */
.output-gate { .output-gate {
+62
View File
@@ -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%;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.jsx"; import DashboardApp from "./dashboard/DashboardApp.jsx";
import DisplayApp from "./display/DisplayApp.jsx"; import DisplayApp from "./display/DisplayApp.jsx";
const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display"; const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
@@ -18,5 +18,5 @@ if (isDisplayRoute && "serviceWorker" in navigator) {
} }
createRoot(document.getElementById("root")).render( createRoot(document.getElementById("root")).render(
<StrictMode>{isDisplayRoute ? <DisplayApp /> : <App />}</StrictMode>, <StrictMode>{isDisplayRoute ? <DisplayApp /> : <DashboardApp />}</StrictMode>,
); );
+9 -1
View File
@@ -13,7 +13,13 @@ services:
api: api:
condition: service_healthy condition: service_healthy
networks: 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: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s interval: 15s
@@ -110,6 +116,8 @@ networks:
app: app:
storage: storage:
internal: true internal: true
tabernet:
external: true
volumes: volumes:
app-data: app-data:
+64
View File
@@ -10,6 +10,18 @@ importers:
client: client:
dependencies: dependencies:
'@dnd-kit/core':
specifier: 6.3.1
version: 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/sortable':
specifier: 10.0.0
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
'@dnd-kit/utilities':
specifier: 3.2.2
version: 3.2.2([email protected])
'@fontsource/roboto':
specifier: 5.2.5
version: 5.2.5
react: react:
specifier: ^19.2.6 specifier: ^19.2.6
version: 19.2.8 version: 19.2.8
@@ -223,6 +235,28 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/[email protected]': '@emnapi/[email protected]':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
@@ -265,6 +299,9 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@fontsource/[email protected]':
resolution: {integrity: sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==}
'@humanfs/[email protected]': '@humanfs/[email protected]':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -1918,6 +1955,31 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@dnd-kit/accessibility': 3.1.1([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.8
react-dom: 19.2.8([email protected])
tslib: 2.8.1
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.8
tslib: 2.8.1
'@emnapi/[email protected]': '@emnapi/[email protected]':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -1957,6 +2019,8 @@ snapshots:
'@eslint/core': 1.2.1 '@eslint/core': 1.2.1
levn: 0.4.1 levn: 0.4.1
'@fontsource/[email protected]': {}
'@humanfs/[email protected]': '@humanfs/[email protected]':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0