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:
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
<StrictMode>{isDisplayRoute ? <DisplayApp /> : <App />}</StrictMode>,
|
||||
<StrictMode>{isDisplayRoute ? <DisplayApp /> : <DashboardApp />}</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user