diff --git a/README.md b/README.md index 3c5b8d9..19ec998 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ Only the Nginx web service publishes a host port (5175 by default). The API and SeaweedFS S3 endpoint remain private on Compose networks. Replace the example S3 credentials before starting the stack. +The web service also joins the external `tabernet` network under the alias +`announcements-web`, so a shared reverse proxy reaches it as +`announcements-web:80`. The API and SeaweedFS deliberately stay off that +network and remain private to Announcements. The network must already exist: + +```sh +docker network create tabernet # only if it does not already exist +``` + ## Library assets `POST /api/assets` accepts multipart uploads in the repeated `file` or `files` @@ -361,6 +370,95 @@ become Home Assistant notifications. Structured display-side errors such as decode failures arrive with the Phase 5 renderer, which will report them through this same field and event. +## Management dashboard + +`/` is the management dashboard and `/display` is the signage output. Both come +from the same single-page build; the dashboard's styling is scoped to its own +route and never applies to the kiosk. + +Management is desktop-only in v2. Below 768px the dashboard shows a short +splash asking for a larger screen. That restriction never applies to +`/display`, which renders signage on any screen including a phone. + +Opening `/` goes straight to the playlist that is actually active — no playlist +has to be chosen first. The header carries the playlist switcher, the +activation state, the display status, and the upload and library actions. + +### Playlists and activation + +The switcher changes which playlist is being *edited*. Selecting a playlist +never activates it: an inactive playlist shows `NOT ACTIVE` with an +**Activate Playlist** button, and activation goes through +`POST /api/playlists/:playlistId/activate`. + +Activation is the server's decision, not the dashboard's. If playback queues the +request to the end of the current slide, the header shows `STARTING SOON` and +keeps showing the old playlist as active until authoritative playback promotes +it. The dashboard never claims a playlist is active before the server says so. +Basic playlist creation is available from the header. + +### Slide tiles + +Entries render as large visual tiles in a responsive grid. Position in the grid +is the sequence, so tiles carry no ordinal numbers. Names are shown without file +extensions and uppercased visually in Roboto; the stored display name keeps its +original case. + +Each tile carries a status ring, always accompanied by the same status in text +so nothing depends on colour alone: + +| Ring | Meaning | +| --- | --- | +| Green | Enabled and currently playable | +| Gray | Disabled | +| Amber | Enabled but outside its scheduling window | +| Red | The asset is unavailable or expired | + +Clicking a tile toggles that **playlist entry's** enabled state. It never +changes the library asset, and the same asset stays independently enabled in +other playlists. Tiles are dragged to reorder, and the new order is persisted +through the playlist API; if the server rejects it, the authoritative order is +restored and a message explains why. React identity is always the stable +PlaylistEntry UUID, never an array index or position. + +An empty playlist and a playlist whose slides are all currently unplayable are +different situations, and the dashboard says which one it is. + +### Uploads and the library + +**Upload Slides** accepts PNG, JPEG, and WebP through the file picker or by +dropping files anywhere on the dashboard. Multiple files upload in the order +they were selected and are appended to the end of the playlist. Each file shows +its own state — queued, uploading, processing, added, already in the library, +already in this playlist, or failed — and the dashboard stays usable throughout. +PDFs and other formats are refused before upload. + +Content identity is the SHA-256 of the original bytes, so re-uploading the same +image reuses the existing library asset instead of storing another copy. A +matching filename with different content is a different asset and is never +overwritten. An asset can appear in many playlists but only once per playlist. + +The **Library** panel browses existing assets with a name filter, so images can +be reused without re-uploading. Assets already in the current playlist are shown +as such and cannot be added twice. An asset with no expiration is permanent and +is never presented as stale; one with an expiration shows when it expires. + +### Live preview and status + +The dashboard shows a miniature of the real signage output driven by +authoritative `playback:state`. It is not a bare thumbnail: it reuses the same +presentation resolver and the same scene stylesheet as `/display`, so 16:9 fill, +uncropped containment, blurred, solid, and black backgrounds, darkening, +foreground shadow, static targets, and the mode-off black gate all render with +identical semantics and cannot drift apart. The preview deliberately does not +take on kiosk responsibilities — no Service Worker, no offline rotation, no +heartbeat, and no display identity of its own. + +Alongside it, one compact indicator reports whether the kiosk is actually +online, with last-seen and cached-slide detail, plus the current transport state +(rotating, paused, static, or output off). Transport is read-only here; MQTT and +the API remain the control paths. + ## Signage display `/display` is the signage output and `/` is reserved for the future management diff --git a/client/e2e/dashboard.spec.js b/client/e2e/dashboard.spec.js new file mode 100644 index 0000000..1d2f33d --- /dev/null +++ b/client/e2e/dashboard.spec.js @@ -0,0 +1,589 @@ +import { expect, test } from "@playwright/test"; +import { ensureFixtures, makePng } from "./fixtures.js"; +import { api, resetPlayback } from "./helpers.js"; + +const BASE = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199"; + +async function freshPlaylist(request, name, assetIds) { + const playlist = await api(request, "/api/playlists", { + method: "POST", + data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 7)}` }, + }); + for (const assetId of assetIds) { + await api(request, `/api/playlists/${playlist.id}/entries`, { + method: "POST", + data: { assetId, durationOverrideMs: 60_000 }, + }); + } + return api(request, `/api/playlists/${playlist.id}`); +} + +async function openDashboardOn(page, playlistName) { + await page.goto(`${BASE}/`); + await page.waitForSelector('[data-testid="playlist-select"]'); + await page.selectOption('[data-testid="playlist-select"]', { label: playlistName }); + await expect(page.locator("h1")).toHaveText(playlistName); +} + +function tiles(page) { + return page.locator('[data-testid="tile"]'); +} + +test.beforeEach(async ({ request }) => { + await ensureFixtures(request); + await resetPlayback(request); +}); + +test("the dashboard opens on the active playlist and the signage route still works", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Active open", [ + library["wide-a"].id, + library["wide-b"].id, + ]); + const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { + method: "POST", + }); + if (activation.activation === "pending") { + await api(request, "/api/playback/next", { method: "POST" }); + } + + await page.goto(`${BASE}/`); + // No playlist has to be chosen before something is visible. + await expect(page.locator("h1")).toHaveText(playlist.name, { timeout: 30_000 }); + await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "active"); + await expect(tiles(page)).toHaveCount(2); + await expect(page.getByTestId("scene-preview")).toBeVisible(); + + // The signage route is untouched by the dashboard. + await page.goto(`${BASE}/display`); + await expect(page.getByTestId("display-root")).toBeVisible(); + await expect(page.getByTestId("dashboard")).toHaveCount(0); +}); + +test("selecting another playlist never activates it; activation is explicit", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const active = await freshPlaylist(request, "Stays active", [library["wide-a"].id]); + const other = await freshPlaylist(request, "Guest Church", [library["wide-b"].id]); + const activation = await api(request, `/api/playlists/${active.id}/activate`, { + method: "POST", + }); + if (activation.activation === "pending") { + await api(request, "/api/playback/next", { method: "POST" }); + } + + await openDashboardOn(page, other.name); + await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "inactive"); + await expect(page.getByTestId("activate-button")).toBeVisible(); + + // Merely viewing it must not have changed the server's active playlist. + expect((await api(request, "/api/playback")).activePlaylistId).toBe(active.id); + + await page.getByTestId("activate-button").click(); + // The server decides immediate versus queued; the UI must not fake either. + await expect + .poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), { + timeout: 30_000, + }) + .toMatch(/^(active|pending)$/); + + const state = await page.getByTestId("activation-state").getAttribute("data-state"); + const playback = await api(request, "/api/playback"); + if (state === "pending") { + expect(playback.pendingPlaylistId).toBe(other.id); + expect(playback.activePlaylistId).toBe(active.id); + // Once the boundary passes, authoritative state promotes it with no reload. + await api(request, "/api/playback/next", { method: "POST" }); + } + await expect + .poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), { + timeout: 45_000, + }) + .toBe("active"); + expect((await api(request, "/api/playback")).activePlaylistId).toBe(other.id); +}); + +test("tiles show artwork, clean names, and accurate status without relying on colour", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Statuses", [ + library["wide-a"].id, + library["wide-b"].id, + library["wide-c"].id, + ]); + await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[1].id}`, { + method: "PATCH", + data: { enabled: false }, + }); + await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[2].id}`, { + method: "PATCH", + data: { scheduleStartAt: Date.now() + 86_400_000 }, + }); + + await openDashboardOn(page, playlist.name); + await expect(tiles(page)).toHaveCount(3); + + const rings = await tiles(page).evaluateAll((nodes) => + nodes.map((node) => ({ + ring: node.dataset.ring, + entryId: node.dataset.entryId, + name: node.querySelector(".tile-name").textContent, + status: node.querySelector('[data-testid="tile-status"]').textContent, + transform: getComputedStyle(node.querySelector(".tile-name")).textTransform, + font: getComputedStyle(node.querySelector(".tile-name")).fontFamily, + hasImage: Boolean(node.querySelector("img.tile-image")), + label: node.querySelector(".tile-surface").getAttribute("aria-label"), + })), + ); + + expect(rings.map((tile) => tile.ring)).toEqual(["playable", "disabled", "scheduled"]); + expect(rings[0].status).toBe("Enabled"); + expect(rings[1].status).toBe("Disabled"); + expect(rings[2].status).toContain("Scheduled"); + // Status is described in text as well as colour, and reaches the accessible name. + for (const tile of rings) expect(tile.label).toContain(tile.status); + + for (const tile of rings) { + expect(tile.hasImage).toBe(true); + expect(tile.name, "file extensions are never shown").not.toMatch(/\.(png|jpe?g|webp)$/i); + // Uppercase is presentation only; the stored name keeps its case. + expect(tile.transform).toBe("uppercase"); + expect(tile.font).toContain("Roboto"); + } + const stored = await api(request, `/api/playlists/${playlist.id}`); + expect(stored.entries[0].asset.displayName).toBe(library["wide-a"].displayName); + + // Stable PlaylistEntry UUIDs are the identity, never positions. + expect(rings.map((tile) => tile.entryId)).toEqual( + playlist.entries.map((entry) => entry.id), + ); + for (const tile of rings) expect(tile.entryId).toMatch(/^[0-9a-f-]{36}$/); +}); + +test("clicking a tile toggles only that playlist entry", async ({ page, request }) => { + const library = await ensureFixtures(request); + const first = await freshPlaylist(request, "Toggle A", [library["wide-a"].id]); + const second = await freshPlaylist(request, "Toggle B", [library["wide-a"].id]); + + await openDashboardOn(page, first.name); + const tile = tiles(page).first(); + await expect(tile).toHaveAttribute("data-ring", "playable"); + await tile.locator(".tile-surface").click(); + await expect(tile).toHaveAttribute("data-ring", "disabled"); + + // Server-confirmed, and scoped to this playlist entry only. + const reloadedFirst = await api(request, `/api/playlists/${first.id}`); + expect(reloadedFirst.entries[0].enabled).toBe(false); + const reloadedSecond = await api(request, `/api/playlists/${second.id}`); + expect(reloadedSecond.entries[0].enabled, "the same asset elsewhere is untouched").toBe(true); + expect( + (await api(request, `/api/assets/${library["wide-a"].id}`)).status, + "the library asset itself is never modified", + ).toBe("ready"); + + await tile.locator(".tile-surface").click(); + await expect(tile).toHaveAttribute("data-ring", "playable"); +}); + +test("dragging a tile reorders the playlist and the order survives a refresh", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Reorder", [ + library["wide-a"].id, + library["wide-b"].id, + library["wide-c"].id, + library["wide-d"].id, + ]); + await openDashboardOn(page, playlist.name); + await expect(tiles(page)).toHaveCount(4); + + const originalIds = await tiles(page).evaluateAll((nodes) => + nodes.map((node) => node.dataset.entryId), + ); + + // Drag the first tile onto the last position. + const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]'); + const target = tiles(page).nth(3); + const from = await source.boundingBox(); + const to = await target.boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 25 }); + await page.mouse.up(); + + const expected = [originalIds[1], originalIds[2], originalIds[3], originalIds[0]]; + await expect + .poll( + async () => tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)), + { timeout: 20_000 }, + ) + .toEqual(expected); + + // The backend holds the same order with contiguous zero-based positions. + const stored = await api(request, `/api/playlists/${playlist.id}`); + expect(stored.entries.map((entry) => entry.id)).toEqual(expected); + expect(stored.entries.map((entry) => entry.position)).toEqual([0, 1, 2, 3]); + + // A reload correctly reopens on the active playlist, so navigate back to the + // one being edited and confirm the order persisted. + await page.reload(); + await openDashboardOn(page, playlist.name); + await expect + .poll(async () => + tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)), + ) + .toEqual(expected); +}); + +test("a rejected reorder restores the authoritative order without losing the grid", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Reorder fail", [ + library["wide-a"].id, + library["wide-b"].id, + library["wide-c"].id, + ]); + await openDashboardOn(page, playlist.name); + const originalIds = await tiles(page).evaluateAll((nodes) => + nodes.map((node) => node.dataset.entryId), + ); + + await page.route("**/entries/order", (route) => + route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ error: { code: "PLAYLIST_ORDER_INCOMPLETE", message: "stale" } }), + }), + ); + + const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]'); + const target = tiles(page).nth(2); + const from = await source.boundingBox(); + const to = await target.boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 20 }); + await page.mouse.up(); + + await expect(page.getByTestId("toast")).toContainText("Could not save the new order"); + await expect + .poll(async () => + tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)), + ) + .toEqual(originalIds); + expect((await api(request, `/api/playlists/${playlist.id}`)).entries.map((e) => e.id)).toEqual( + originalIds, + ); +}); + +test("uploads append in selection order and report duplicates without blocking", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Uploads", [library["wide-a"].id]); + await openDashboardOn(page, playlist.name); + + // Content identity is the SHA-256 of the bytes, so each run needs genuinely + // new pixels; reusing fixture bytes would correctly deduplicate instead. + const unique = `${Date.now()}`; + const seed = Date.now() % 200; + const files = [ + { name: `alpha-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [seed, 20, 30]) }, + { name: `beta-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [40, seed, 60]) }, + { name: `gamma-${unique}.png`, mimeType: "image/png", buffer: makePng(1200, 1200, [70, 80, seed]) }, + ]; + await page.getByTestId("file-input").setInputFiles(files); + + // The dashboard stays usable while uploads run. + await expect(page.getByTestId("upload-queue")).toBeVisible(); + await expect(page.getByTestId("playlist-select")).toBeEnabled(); + + await expect(tiles(page)).toHaveCount(4, { timeout: 90_000 }); + const names = await tiles(page).evaluateAll((nodes) => + nodes.map((node) => node.querySelector(".tile-name").textContent), + ); + // Appended at the end, in the order they were selected. + expect(names.slice(1)).toEqual([ + `alpha-${unique}`.toLowerCase(), + `beta-${unique}`.toLowerCase(), + `gamma-${unique}`.toLowerCase(), + ]); + await expect(page.locator('[data-testid="upload-item"][data-state="complete"]')).toHaveCount(3); + + // Re-uploading identical bytes is recognised as content already present. + await page.getByTestId("file-input").setInputFiles([files[0]]); + await expect( + page.locator('[data-testid="upload-item"][data-state="already_in_playlist"]'), + ).toHaveCount(1, { timeout: 60_000 }); + await expect(tiles(page), "no duplicate entry is created").toHaveCount(4); + + // A PDF is refused before it reaches the upload pipeline. + await page.getByTestId("file-input").setInputFiles([ + { name: "handout.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") }, + ]); + await expect(page.locator('[data-testid="upload-item"][data-state="failed"]')).toContainText( + "PNG, JPEG, and WebP", + ); + await expect(tiles(page)).toHaveCount(4); +}); + +test("dropping files onto the dashboard uploads them", async ({ page, request }) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Drop", [library["wide-a"].id]); + await openDashboardOn(page, playlist.name); + + const unique = `drop-${Date.now()}`; + // Unique bytes so this is a new asset rather than a content duplicate. + const buffer = makePng(1920, 1080, [Date.now() % 200, 45, 67]).toString("base64"); + await page.evaluate( + async ([name, base64]) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const file = new File([bytes], `${name}.png`, { type: "image/png" }); + const transfer = new DataTransfer(); + transfer.items.add(file); + const root = document.querySelector('[data-testid="dashboard"]'); + root.dispatchEvent(new DragEvent("dragover", { dataTransfer: transfer, bubbles: true })); + root.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true })); + }, + [unique, buffer], + ); + + await expect(tiles(page)).toHaveCount(2, { timeout: 90_000 }); + await expect(tiles(page).last().locator(".tile-name")).toHaveText(unique); +}); + +test("the library reuses existing assets and refuses duplicates within a playlist", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Library reuse", [library["wide-a"].id]); + await openDashboardOn(page, playlist.name); + + await page.getByTestId("library-button").click(); + await expect(page.getByTestId("library-panel")).toBeVisible(); + + // An asset already in this playlist cannot be added twice. + const already = page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-a"].id}"]`); + await expect(already).toHaveAttribute("data-in-playlist", "true"); + await expect(already.locator("button")).toBeDisabled(); + + // A permanent asset must never be presented as expired. + await expect(already).not.toContainText("Expired"); + + // Add two different assets at once. + await page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-b"].id}"] button`).click(); + await page.locator(`[data-testid="library-item"][data-asset-id="${library.square.id}"] button`).click(); + await page.getByRole("button", { name: /Add 2 to playlist/ }).click(); + + await expect(tiles(page)).toHaveCount(3, { timeout: 30_000 }); + const stored = await api(request, `/api/playlists/${playlist.id}`); + expect(stored.entries.map((entry) => entry.assetId)).toEqual([ + library["wide-a"].id, + library["wide-b"].id, + library.square.id, + ]); + + // The same asset is free to appear in a different playlist. + const other = await freshPlaylist(request, "Library reuse other", [library["wide-a"].id]); + expect(other.entries[0].assetId).toBe(library["wide-a"].id); +}); + +test("the live preview renders slides with the same presentation as the signage output", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Preview", [library.portrait.id]); + await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[0].id}`, { + method: "PATCH", + data: { + backgroundModeOverride: "blurred", + backgroundDarkenedOverride: true, + }, + }); + const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { + method: "POST", + }); + if (activation.activation === "pending") { + await api(request, "/api/playback/next", { method: "POST" }); + } + + const read = (target) => + target.evaluate(() => { + const layer = document.querySelector('.scene[data-visible="true"]'); + const background = layer.querySelector(".scene-background-image"); + const foreground = layer.querySelector(".scene-foreground"); + return { + fit: layer.dataset.fit, + backgroundMode: layer.dataset.backgroundMode, + darkened: layer.dataset.darkened, + shadow: layer.dataset.shadow, + backgroundDisplay: getComputedStyle(background).display, + backgroundFit: getComputedStyle(background).objectFit, + backgroundFilter: getComputedStyle(background).filter, + foregroundFit: getComputedStyle(foreground).objectFit, + foregroundFilter: getComputedStyle(foreground).filter, + darkenOpacity: Number(getComputedStyle(layer.querySelector(".scene-darken")).opacity), + }; + }); + + await page.goto(`${BASE}/`); + await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => page.locator('.scene[data-visible="true"]').count(), { timeout: 30_000 }) + .toBe(1); + const preview = await read(page); + + const kiosk = await page.context().newPage(); + await kiosk.goto(`${BASE}/display`); + await expect + .poll(async () => kiosk.locator('.scene[data-visible="true"]').count(), { timeout: 45_000 }) + .toBeGreaterThanOrEqual(1); + await kiosk.waitForTimeout(1_500); + const signage = await read(kiosk); + await kiosk.close(); + + // Proving shared semantics rather than a screenshot likeness. + expect(preview).toEqual(signage); + expect(preview.fit).toBe("contain"); + expect(preview.backgroundMode).toBe("blurred"); + expect(preview.backgroundFilter).toBe("blur(20px)"); + expect(preview.darkenOpacity).toBeCloseTo(0.25, 2); +}); + +test("the preview follows mode off and 16:9 fill exactly like the signage output", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Preview modes", [library["wide-a"].id]); + const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { + method: "POST", + }); + if (activation.activation === "pending") { + await api(request, "/api/playback/next", { method: "POST" }); + } + + await page.goto(`${BASE}/`); + await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => page.locator('.scene[data-visible="true"]').getAttribute("data-fit"), { + timeout: 30_000, + }) + .toBe("fill"); + // A true 16:9 slide fills with no blurred duplicate and no card shadow. + const wide = await page.evaluate(() => { + const layer = document.querySelector('.scene[data-visible="true"]'); + return { + backgroundMode: layer.dataset.backgroundMode, + shadow: layer.dataset.shadow, + backgroundDisplay: getComputedStyle(layer.querySelector(".scene-background-image")).display, + foregroundFilter: getComputedStyle(layer.querySelector(".scene-foreground")).filter, + }; + }); + expect(wide).toEqual({ + backgroundMode: "none", + shadow: "false", + backgroundDisplay: "none", + foregroundFilter: "none", + }); + + await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } }); + await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "true", { + timeout: 30_000, + }); + await expect(page.getByTestId("playback-status")).toContainText("Output Off"); + + await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } }); + await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "false", { + timeout: 30_000, + }); +}); + +test("the dashboard reflects a real display coming online and going offline", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const playlist = await freshPlaylist(request, "Display status", [library["wide-a"].id]); + const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { + method: "POST", + }); + if (activation.activation === "pending") { + await api(request, "/api/playback/next", { method: "POST" }); + } + + await page.goto(`${BASE}/`); + await expect(page.getByTestId("display-status")).toBeVisible({ timeout: 30_000 }); + + // A real kiosk page produces genuine heartbeats. + const kiosk = await page.context().newPage(); + await kiosk.goto(`${BASE}/display`); + await expect + .poll(async () => page.getByTestId("display-status").getAttribute("data-online"), { + timeout: 60_000, + }) + .toBe("true"); + await expect(page.getByTestId("display-status")).toContainText("Display Online"); + + await kiosk.close(); + // Crossing the server's 45s heartbeat threshold, with no page reload. + await expect + .poll(async () => page.getByTestId("display-status").getAttribute("data-online"), { + timeout: 120_000, + }) + .toBe("false"); + await expect(page.getByTestId("display-status")).toContainText("Display Offline"); +}); + +test("an empty playlist explains itself and unplayable slides are distinguished", async ({ + page, + request, +}) => { + const library = await ensureFixtures(request); + const empty = await freshPlaylist(request, "Empty", []); + await openDashboardOn(page, empty.name); + await expect(page.getByTestId("empty-playlist")).toBeVisible(); + await expect(page.getByTestId("empty-playlist")).toContainText("no slides yet"); + + // A playlist with slides that are all currently unplayable is a different + // problem and says so. + const disabled = await freshPlaylist(request, "All disabled", [library["wide-a"].id]); + await api(request, `/api/playlists/${disabled.id}/entries/${disabled.entries[0].id}`, { + method: "PATCH", + data: { enabled: false }, + }); + await openDashboardOn(page, disabled.name); + await expect(page.getByTestId("empty-playlist")).toHaveCount(0); + await expect(page.getByTestId("playable-summary")).toContainText("No slides are playable"); +}); + +test("management requires a desktop while the signage route stays available", async ({ + browser, +}) => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + + await page.goto(`${BASE}/`); + await expect(page.getByTestId("mobile-splash")).toBeVisible(); + await expect(page.getByTestId("tile-grid")).toHaveCount(0); + + // The kiosk output must never be gated by the management restriction. + await page.goto(`${BASE}/display`); + await expect(page.getByTestId("display-root")).toBeVisible(); + await expect(page.getByTestId("mobile-splash")).toHaveCount(0); + await context.close(); +}); diff --git a/client/package.json b/client/package.json index c5622f6..4b32c3b 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,10 @@ "test:unit": "node --test \"src/display/__tests__/*.test.js\"" }, "dependencies": { + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@fontsource/roboto": "5.2.5", "react": "^19.2.6", "react-dom": "^19.2.6", "socket.io-client": "^4.8.3" diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index f225a9a..0000000 --- a/client/src/App.css +++ /dev/null @@ -1,13 +0,0 @@ -.placeholder { - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; - margin: 0 auto; - max-width: 40rem; - padding: 3rem 1.5rem; - color: #1f2933; -} - -.placeholder code { - background: #eef1f5; - border-radius: 0.25rem; - padding: 0.1rem 0.35rem; -} diff --git a/client/src/App.jsx b/client/src/App.jsx deleted file mode 100644 index 8970a50..0000000 --- a/client/src/App.jsx +++ /dev/null @@ -1,12 +0,0 @@ -// Placeholder management route. The dashboard is a later phase; the signage -// output lives at /display. -export default function App() { - return ( -
-

Announcements

-

- The signage output is served at /display. -

-
- ); -} diff --git a/client/src/dashboard/DashboardApp.jsx b/client/src/dashboard/DashboardApp.jsx new file mode 100644 index 0000000..b9ff385 --- /dev/null +++ b/client/src/dashboard/DashboardApp.jsx @@ -0,0 +1,522 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { io } from "socket.io-client"; +import "@fontsource/roboto/400.css"; +import "@fontsource/roboto/500.css"; +import { api, isAcceptedFile, uploadAsset } from "./api.js"; +import LibraryPanel from "./LibraryPanel.jsx"; +import PlaylistTiles from "./PlaylistTiles.jsx"; +import StatusPanel from "./StatusPanel.jsx"; +import Toasts from "./Toasts.jsx"; +import UploadQueue from "./UploadQueue.jsx"; +import "./dashboard.css"; + +const DESKTOP_MIN_WIDTH = 769; +let uploadSequence = 0; + +function MobileSplash() { + return ( +
+

Announcements

+

Management needs a desktop-sized screen.

+

+ Open this page on a computer to manage playlists and slides. The signage + output at /display works on any screen. +

+
+ ); +} + +export default function DashboardApp() { + const [wideEnough, setWideEnough] = useState( + () => window.innerWidth >= DESKTOP_MIN_WIDTH, + ); + const [playlists, setPlaylists] = useState([]); + const [playlist, setPlaylist] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [displays, setDisplays] = useState([]); + const [assets, setAssets] = useState([]); + const [libraryOpen, setLibraryOpen] = useState(false); + const [uploads, setUploads] = useState([]); + const [toasts, setToasts] = useState([]); + const [busyEntryIds, setBusyEntryIds] = useState(() => new Set()); + const [dropping, setDropping] = useState(false); + const [activating, setActivating] = useState(false); + const fileInputRef = useRef(null); + const selectedIdRef = useRef(null); + // Async loads check this to discard responses for a playlist the operator + // has already navigated away from. Refs must not be written during render. + useEffect(() => { + selectedIdRef.current = selectedId; + }, [selectedId]); + + useEffect(() => { + const onResize = () => setWideEnough(window.innerWidth >= DESKTOP_MIN_WIDTH); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + const notify = useCallback((message, tone = "info") => { + const id = `toast-${++uploadSequence}`; + setToasts((current) => [...current, { id, message, tone }]); + const timer = setTimeout( + () => setToasts((current) => current.filter((toast) => toast.id !== id)), + 6_000, + ); + return () => clearTimeout(timer); + }, []); + + const loadPlaylist = useCallback(async (playlistId) => { + if (!playlistId) { + setPlaylist(null); + return null; + } + const detail = await api.getPlaylist(playlistId); + // Only apply if the operator is still looking at this playlist. + if (selectedIdRef.current === playlistId) setPlaylist(detail); + return detail; + }, []); + + const refreshPlaylists = useCallback(async () => { + const list = await api.listPlaylists(); + setPlaylists(list); + return list; + }, []); + + // Initial load: open directly on whatever is actually active. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [list, playback, displayList] = await Promise.all([ + api.listPlaylists(), + api.getPlayback(), + api.listDisplays().catch(() => []), + ]); + if (cancelled) return; + setPlaylists(list); + setSnapshot(playback); + setDisplays(displayList); + const active = list.find((item) => item.isActive) || list[0] || null; + if (active) { + setSelectedId(active.id); + selectedIdRef.current = active.id; + await loadPlaylist(active.id); + } + } catch (error) { + if (!cancelled) notify(error.message, "error"); + } + })(); + return () => { + cancelled = true; + }; + }, [loadPlaylist, notify]); + + // Socket handlers are registered before connecting, and torn down on unmount + // so a StrictMode remount cannot leave duplicates behind. + useEffect(() => { + const socket = io({ autoConnect: false }); + const onPlayback = (next) => setSnapshot(next); + const onPlaylists = () => { + refreshPlaylists().catch(() => {}); + if (selectedIdRef.current) loadPlaylist(selectedIdRef.current).catch(() => {}); + }; + const onDisplay = () => { + api.listDisplays().then(setDisplays).catch(() => {}); + }; + socket.on("playback:state", onPlayback); + socket.on("playlists:changed", onPlaylists); + socket.on("display:status", onDisplay); + socket.connect(); + return () => { + socket.off("playback:state", onPlayback); + socket.off("playlists:changed", onPlaylists); + socket.off("display:status", onDisplay); + socket.disconnect(); + }; + }, [loadPlaylist, refreshPlaylists]); + + // Heartbeat freshness decides online/offline, so poll the status endpoint at + // a low rate rather than trusting the last event forever. + useEffect(() => { + const timer = setInterval(() => { + api.listDisplays().then(setDisplays).catch(() => {}); + }, 10_000); + return () => clearInterval(timer); + }, []); + + const activePlaylistId = snapshot?.activePlaylistId ?? null; + const pendingPlaylistId = snapshot?.pendingPlaylistId ?? null; + const isActive = playlist && playlist.id === activePlaylistId; + const isPending = playlist && playlist.id === pendingPlaylistId; + + async function selectPlaylist(playlistId) { + // Selecting a playlist only changes what is being edited. It never + // activates anything. + setSelectedId(playlistId); + selectedIdRef.current = playlistId; + setPlaylist(null); + try { + await loadPlaylist(playlistId); + } catch (error) { + notify(error.message, "error"); + } + } + + async function activateSelected() { + if (!playlist) return; + setActivating(true); + try { + const result = await api.activatePlaylist(playlist.id); + // The server decides whether this is immediate or queued to a boundary. + notify( + result.activation === "pending" + ? `${playlist.name} will start when the current slide finishes` + : `${playlist.name} is now active`, + "success", + ); + setSnapshot(result.snapshot ?? (await api.getPlayback())); + await refreshPlaylists(); + } catch (error) { + notify(error.message, "error"); + } finally { + setActivating(false); + } + } + + async function createPlaylist() { + const name = `Playlist ${new Date().toLocaleDateString()}`; + try { + const created = await api.createPlaylist(name); + await refreshPlaylists(); + await selectPlaylist(created.id); + notify(`Created ${created.name}`, "success"); + } catch (error) { + notify(error.message, "error"); + } + } + + async function toggleEntry(entry) { + if (!playlist) return; + setBusyEntryIds((current) => new Set(current).add(entry.id)); + try { + await api.updateEntry(playlist.id, entry.id, { enabled: !entry.enabled }); + await loadPlaylist(playlist.id); + } catch (error) { + notify(error.message, "error"); + await loadPlaylist(playlist.id).catch(() => {}); + } finally { + setBusyEntryIds((current) => { + const next = new Set(current); + next.delete(entry.id); + return next; + }); + } + } + + async function reorderEntries(entryIds) { + if (!playlist) return; + try { + const detail = await api.reorderEntries(playlist.id, entryIds); + if (selectedIdRef.current === playlist.id) setPlaylist(detail); + } catch (error) { + notify(`Could not save the new order: ${error.message}`, "error"); + // Restore whatever the server actually holds. + await loadPlaylist(playlist.id).catch(() => {}); + throw error; + } + } + + async function addAssets(assetIds) { + if (!playlist) return; + for (const assetId of assetIds) { + try { + await api.addEntry(playlist.id, assetId); + } catch (error) { + notify( + error.code === "PLAYLIST_ENTRY_EXISTS" + ? "That slide is already in this playlist" + : error.message, + error.code === "PLAYLIST_ENTRY_EXISTS" ? "info" : "error", + ); + } + } + await loadPlaylist(playlist.id).catch(() => {}); + } + + function patchUpload(id, patch) { + setUploads((current) => + current.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + } + + // Uploads run sequentially so appended entries keep the operator's original + // selection order rather than whichever request finished first. + async function uploadFiles(fileList) { + const files = [...fileList]; + if (files.length === 0) return; + const accepted = files.filter(isAcceptedFile); + const rejected = files.filter((file) => !isAcceptedFile(file)); + for (const file of rejected) { + const id = `upload-${++uploadSequence}`; + setUploads((current) => [ + ...current, + { + id, + name: file.name, + state: "failed", + progress: 0, + message: "Only PNG, JPEG, and WebP images are supported", + }, + ]); + } + const queued = accepted.map((file) => ({ + file, + id: `upload-${++uploadSequence}`, + })); + setUploads((current) => [ + ...current, + ...queued.map(({ file, id }) => ({ + id, + name: file.name, + state: "queued", + progress: 0, + message: null, + })), + ]); + + for (const { file, id } of queued) { + try { + patchUpload(id, { state: "uploading" }); + const result = await uploadAsset(file, { + onProgress: (progress) => patchUpload(id, { progress }), + }); + patchUpload(id, { state: "processing", progress: 1 }); + const record = result?.asset ? result : result?.results?.[0]; + const asset = record?.asset; + if (!asset) { + patchUpload(id, { state: "failed", message: "Upload returned no asset" }); + continue; + } + const duplicate = record.duplicate || record.created === false; + if (playlist) { + try { + await api.addEntry(playlist.id, asset.id); + patchUpload(id, { state: duplicate ? "duplicate" : "complete" }); + } catch (error) { + if (error.code === "PLAYLIST_ENTRY_EXISTS") { + patchUpload(id, { + state: "already_in_playlist", + message: "This image is already in this playlist", + }); + } else { + patchUpload(id, { state: "failed", message: error.message }); + } + } + } else { + patchUpload(id, { state: duplicate ? "duplicate" : "complete" }); + } + } catch (error) { + patchUpload(id, { + state: "failed", + message: + error.code === "ASSET_NAME_CONFLICT" + ? "Another slide already uses that name" + : error.message, + }); + } + } + + await Promise.all([ + loadPlaylist(selectedIdRef.current).catch(() => {}), + api.listAssets().then(setAssets).catch(() => {}), + ]); + } + + async function openLibrary() { + try { + setAssets(await api.listAssets()); + setLibraryOpen(true); + } catch (error) { + notify(error.message, "error"); + } + } + + const activationState = useMemo(() => { + if (!playlist) return null; + if (isActive) return "active"; + if (isPending) return "pending"; + return "inactive"; + }, [playlist, isActive, isPending]); + + if (!wideEnough) return ; + + return ( +
{ + event.preventDefault(); + setDropping(true); + }} + onDragLeave={(event) => { + if (event.currentTarget === event.target) setDropping(false); + }} + onDrop={(event) => { + event.preventDefault(); + setDropping(false); + if (event.dataTransfer?.files?.length) void uploadFiles(event.dataTransfer.files); + }} + > +
+
+ Announcements +
+ + + +
+
+ +
+ {activationState === "active" ? ( + ACTIVE + ) : activationState === "pending" ? ( + <> + STARTING SOON + Waiting for the current slide to finish + + ) : activationState === "inactive" ? ( + <> + NOT ACTIVE + + + ) : null} +
+ +
+ + + { + void uploadFiles(event.target.files); + event.target.value = ""; + }} + /> +
+
+ +
+
+ {playlist ? ( + <> +
+

{playlist.name}

+ +
+ fileInputRef.current?.click()} + onLibraryRequest={openLibrary} + /> + + ) : ( +
+ +
+ + + setUploads((current) => + current.filter((item) => item.state === "uploading" || item.state === "queued"), + ) + } + /> + {libraryOpen && playlist ? ( + setLibraryOpen(false)} + onAdd={addAssets} + /> + ) : null} + setToasts((c) => c.filter((t) => t.id !== id))} /> + {dropping ? ( +
+ Drop images to add them to this playlist +
+ ) : null} +
+ ); +} + +// An empty playlist and a playlist whose slides are all currently unplayable +// are different problems, so they are explained differently. +function PlayableSummary({ playlist }) { + const total = playlist.entries.length; + const playable = playlist.entries.filter((entry) => entry.playable).length; + if (total === 0) return null; + if (playable > 0) { + return ( +

+ {playable} of {total} slides playing now +

+ ); + } + return ( +

+ No slides are playable right now — they are disabled, outside their schedule, or unavailable. +

+ ); +} diff --git a/client/src/dashboard/LibraryPanel.jsx b/client/src/dashboard/LibraryPanel.jsx new file mode 100644 index 0000000..b2d10a6 --- /dev/null +++ b/client/src/dashboard/LibraryPanel.jsx @@ -0,0 +1,116 @@ +import { useEffect, useMemo, useState } from "react"; +import { slideLabel } from "./api.js"; + +function expirationLabel(asset, now) { + // A permanent asset has no expiration and must never look stale merely + // because it is old. + if (asset.expirationAt === null || asset.expirationAt === undefined) return null; + const when = new Date(asset.expirationAt); + if (asset.expirationAt <= now) return "Expired"; + return `Expires ${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`; +} + +export default function LibraryPanel({ assets, playlist, onAdd, onClose, busy }) { + const [filter, setFilter] = useState(""); + const [selected, setSelected] = useState(() => new Set()); + // Reading the clock during render is impure; it is sampled here instead and + // refreshed slowly, which is ample for an expiry label. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + + const inPlaylist = useMemo( + () => new Set(playlist.entries.map((entry) => entry.assetId)), + [playlist.entries], + ); + + const visible = useMemo(() => { + const needle = filter.trim().toLowerCase(); + return assets.filter((asset) => { + if (!needle) return true; + return slideLabel(asset).toLowerCase().includes(needle); + }); + }, [assets, filter]); + + function toggle(asset, addable) { + if (!addable) return; + setSelected((current) => { + const next = new Set(current); + if (next.has(asset.id)) next.delete(asset.id); + else next.add(asset.id); + return next; + }); + } + + return ( + + ); +} diff --git a/client/src/dashboard/PlaylistTiles.jsx b/client/src/dashboard/PlaylistTiles.jsx new file mode 100644 index 0000000..3d4e188 --- /dev/null +++ b/client/src/dashboard/PlaylistTiles.jsx @@ -0,0 +1,166 @@ +import { useMemo, useState } from "react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { slideLabel } from "./api.js"; +import { entryStatus } from "./entryStatus.js"; + +function Tile({ entry, onToggle, busy }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: entry.id, + }); + const status = entryStatus(entry); + const label = slideLabel(entry.asset); + + return ( +
  • + + + + + + {label} + +
  • + ); +} + +export default function PlaylistTiles({ + playlist, + onToggleEntry, + onReorder, + onUploadRequest, + onLibraryRequest, + busyEntryIds, +}) { + // Local order exists only so a drag feels immediate; the server remains the + // source of truth. Authoritative entries are adopted during render rather + // than synced in an effect, so no cascading render is needed. + const authoritative = useMemo( + () => playlist.entries.map((entry) => entry.id), + [playlist.entries], + ); + const [order, setOrder] = useState(authoritative); + const [adopted, setAdopted] = useState(authoritative); + if (adopted !== authoritative) { + setAdopted(authoritative); + setOrder(authoritative); + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const byId = useMemo( + () => new Map(playlist.entries.map((entry) => [entry.id, entry])), + [playlist.entries], + ); + const entries = order.map((id) => byId.get(id)).filter(Boolean); + + if (playlist.entries.length === 0) { + return ( +
    +

    This playlist has no slides yet

    +

    Drop images anywhere on this page, or use the actions below.

    +
    + + +
    +
    + ); + } + + async function handleDragEnd(event) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const from = order.indexOf(active.id); + const to = order.indexOf(over.id); + if (from < 0 || to < 0) return; + const next = arrayMove(order, from, to); + const previous = order; + setOrder(next); + try { + await onReorder(next); + } catch { + // The authoritative order wins; the grid snaps back without losing work. + setOrder(previous); + } + } + + return ( + + + {/* Position in the grid is the sequence; no numbers are stamped on tiles. */} +
      + {entries.map((entry) => ( + + ))} +
    +
    +
    + ); +} diff --git a/client/src/dashboard/StatusPanel.jsx b/client/src/dashboard/StatusPanel.jsx new file mode 100644 index 0000000..748b77d --- /dev/null +++ b/client/src/dashboard/StatusPanel.jsx @@ -0,0 +1,64 @@ +import ScenePreview from "../display/ScenePreview.jsx"; + +const TRANSPORT_LABELS = { + playing: "Rotating", + paused: "Paused", + static: "Static", + idle: "No playable slides", +}; + +function relativeTime(iso) { + if (!iso) return "never"; + const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours} h ago`; + return `${Math.round(hours / 24)} d ago`; +} + +export default function StatusPanel({ snapshot, displays }) { + // v2 has a single global output; if stray records exist from testing, prefer + // any that is online rather than showing an arbitrary one. + const display = displays.find((item) => item.status === "online") || displays[0] || null; + const online = display?.status === "online"; + const transport = snapshot?.mode === "off" ? "Output Off" : TRANSPORT_LABELS[snapshot?.transportState] || "—"; + + const detail = display + ? [ + `Last seen ${relativeTime(display.lastSeenAt)}`, + display.cachedAssetCount !== null && display.cachedAssetCount !== undefined + ? `${display.cachedAssetCount} slides cached` + : null, + display.mostRecentError + ? `Error: ${display.mostRecentError.kind || display.mostRecentError}` + : null, + ] + .filter(Boolean) + .join(" · ") + : "No display has connected yet"; + + return ( +
    +
    + +
    +
    +
    +
    +
    + {transport} + + {snapshot?.activePlaylist?.name ? `Playing ${snapshot.activePlaylist.name}` : "No active playlist"} + +
    +
    +
    + ); +} diff --git a/client/src/dashboard/Toasts.jsx b/client/src/dashboard/Toasts.jsx new file mode 100644 index 0000000..e72bc7c --- /dev/null +++ b/client/src/dashboard/Toasts.jsx @@ -0,0 +1,16 @@ +// Application-level messaging. The dashboard never uses alert/confirm/prompt. +export default function Toasts({ toasts, onDismiss }) { + if (toasts.length === 0) return null; + return ( +
    + {toasts.map((toast) => ( +
    + {toast.message} + +
    + ))} +
    + ); +} diff --git a/client/src/dashboard/UploadQueue.jsx b/client/src/dashboard/UploadQueue.jsx new file mode 100644 index 0000000..e0bcae1 --- /dev/null +++ b/client/src/dashboard/UploadQueue.jsx @@ -0,0 +1,41 @@ +// Per-file upload state. The dashboard stays usable while these run, and the +// list distinguishes every state the operator needs to act on. +const STATE_LABELS = { + queued: "Queued", + uploading: "Uploading", + processing: "Processing", + complete: "Added", + duplicate: "Already in library", + already_in_playlist: "Already in this playlist", + failed: "Failed", +}; + +export default function UploadQueue({ items, onDismiss }) { + if (items.length === 0) return null; + return ( +
    +
    +

    Uploads

    + +
    + +
    + ); +} diff --git a/client/src/dashboard/api.js b/client/src/dashboard/api.js new file mode 100644 index 0000000..d39951c --- /dev/null +++ b/client/src/dashboard/api.js @@ -0,0 +1,89 @@ +// A small explicit API layer. Components call these helpers instead of +// scattering fetch calls, but this is deliberately not a generated SDK or a +// generic data-repository framework. +async function request(path, { method = "GET", body, signal } = {}) { + const response = await fetch(path, { + method, + signal, + ...(body === undefined + ? {} + : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + }); + const text = await response.text(); + const parsed = text ? JSON.parse(text) : null; + if (!response.ok) { + const error = new Error(parsed?.error?.message || `Request failed (${response.status})`); + error.code = parsed?.error?.code || "REQUEST_FAILED"; + error.status = response.status; + error.details = parsed?.error?.details; + throw error; + } + return parsed; +} + +export const api = { + listPlaylists: () => request("/api/playlists").then((body) => body.playlists), + getPlaylist: (playlistId) => request(`/api/playlists/${playlistId}`), + createPlaylist: (name) => request("/api/playlists", { method: "POST", body: { name } }), + activatePlaylist: (playlistId) => + request(`/api/playlists/${playlistId}/activate`, { method: "POST" }), + addEntry: (playlistId, assetId) => + request(`/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId } }), + updateEntry: (playlistId, entryId, patch) => + request(`/api/playlists/${playlistId}/entries/${entryId}`, { method: "PATCH", body: patch }), + reorderEntries: (playlistId, entryIds) => + request(`/api/playlists/${playlistId}/entries/order`, { + method: "PUT", + body: { entryIds }, + }), + listAssets: () => request("/api/assets").then((body) => body.assets), + listDisplays: () => request("/api/displays").then((body) => body.displays), + getPlayback: () => request("/api/playback"), +}; + +// Uploads use the existing Phase 2 multipart pipeline. The browser never talks +// to object storage directly, and progress needs XHR rather than fetch. +export function uploadAsset(file, { onProgress } = {}) { + return new Promise((resolve, reject) => { + const form = new FormData(); + form.append("files", file, file.name); + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/assets"); + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) onProgress?.(event.loaded / event.total); + }); + xhr.addEventListener("load", () => { + let parsed; + try { + parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null; + } catch { + parsed = null; + } + if (xhr.status >= 200 && xhr.status < 300) { + resolve(parsed); + return; + } + const error = new Error(parsed?.error?.message || `Upload failed (${xhr.status})`); + error.code = parsed?.error?.code || "UPLOAD_FAILED"; + error.status = xhr.status; + reject(error); + }); + xhr.addEventListener("error", () => reject(new Error("Upload failed"))); + xhr.send(form); + }); +} + +export const ACCEPTED_UPLOAD_TYPES = ["image/png", "image/jpeg", "image/webp"]; + +export function isAcceptedFile(file) { + if (ACCEPTED_UPLOAD_TYPES.includes(file.type)) return true; + // Some browsers report an empty type; fall back to the extension. + return /\.(png|jpe?g|webp)$/i.test(file.name || ""); +} + +// Stored display names keep their original case; presentation uppercases them +// with CSS. File extensions are never part of the visible slide name. +export function slideLabel(asset) { + const name = asset?.displayName || asset?.originalFilename || "Untitled"; + return name.replace(/\.(png|jpe?g|webp|gif|bmp|tiff?)$/i, ""); +} diff --git a/client/src/dashboard/dashboard.css b/client/src/dashboard/dashboard.css new file mode 100644 index 0000000..9ebbbe9 --- /dev/null +++ b/client/src/dashboard/dashboard.css @@ -0,0 +1,441 @@ +/* Desktop management surface. Slide imagery dominates; chrome stays quiet. + None of this is applied to /display, which keeps its own scoped styling. */ +.dashboard { + --surface: #0f1419; + --panel: #161d25; + --line: #232d38; + --text: #eef2f6; + --muted: #93a1b0; + --accent: #3b82f6; + --ok: #22c55e; + --warn: #f59e0b; + --bad: #ef4444; + min-height: 100vh; + background: var(--surface); + color: var(--text); + font-family: "Roboto", system-ui, -apple-system, "Segoe UI", sans-serif; + display: flex; + flex-direction: column; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.dashboard-header { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.9rem 1.5rem; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.header-identity { + display: flex; + align-items: center; + gap: 1rem; +} + +.product { + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + font-size: 0.8rem; +} + +.playlist-choice { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.playlist-choice select { + background: var(--surface); + color: var(--text); + border: 1px solid var(--line); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + font: inherit; + min-width: 14rem; +} + +.header-activation { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +.badge { + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.08em; + padding: 0.3rem 0.6rem; + border-radius: 999px; + border: 1px solid currentColor; +} + +.badge.active { color: var(--ok); } +.badge.pending { color: var(--warn); } +.badge.inactive { color: var(--muted); } + +.button { + font: inherit; + border-radius: 0.5rem; + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + padding: 0.5rem 0.9rem; + cursor: pointer; +} + +.button:hover { border-color: var(--muted); } +.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.button[disabled] { opacity: 0.5; cursor: default; } +.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.button.subtle { background: transparent; color: var(--muted); } + +.dashboard-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 22rem; + gap: 1.5rem; + padding: 1.5rem; + flex: 1; + align-items: start; +} + +.playlist-heading h1 { + margin: 0; + font-size: 1.35rem; + font-weight: 500; +} + +.playlist-heading .muted, +.playlist-heading .warning { + margin: 0.25rem 0 1rem; + font-size: 0.85rem; +} + +.muted { color: var(--muted); } +.warning { color: var(--warn); } + +/* Position in the grid is the sequence; tiles carry no ordinal numbers. */ +.tile-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: 1.1rem; +} + +.tile { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tile[data-dragging="true"] { + z-index: 5; + filter: drop-shadow(0 1rem 1.5rem rgba(0, 0, 0, 0.55)); +} + +.tile-surface { + position: relative; + padding: 0; + border: none; + background: #000; + border-radius: 0.7rem; + overflow: hidden; + aspect-ratio: 16 / 9; + cursor: pointer; + display: block; + width: 100%; +} + +.tile-surface:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } + +.tile-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.tile[data-enabled="false"] .tile-image { opacity: 0.4; filter: grayscale(0.7); } +.tile-image-missing { background: #22303c; } + +/* The ring reads clearly without covering the artwork. */ +.tile-ring { + position: absolute; + inset: 0; + border-radius: 0.7rem; + border: 3px solid transparent; + pointer-events: none; +} + +.tile-ring[data-ring="playable"] { border-color: var(--ok); } +.tile-ring[data-ring="disabled"] { border-color: #6b7785; } +.tile-ring[data-ring="scheduled"] { border-color: var(--warn); } +.tile-ring[data-ring="error"] { border-color: var(--bad); } + +/* Status is always available as text, never colour alone. */ +.tile-status { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 0.35rem 0.5rem; + font-size: 0.72rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + color: #fff; + text-align: left; +} + +.tile-drag { + position: absolute; + top: 0.4rem; + right: 0.4rem; + padding: 0.15rem 0.4rem; + border-radius: 0.4rem; + background: rgba(0, 0, 0, 0.55); + color: #fff; + cursor: grab; + font-size: 0.9rem; + line-height: 1.2; +} + +.tile-drag:focus-visible { outline: 2px solid var(--accent); } + +.tile-name { + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0.03em; + /* Presentation only: the stored display name keeps its original case. */ + text-transform: uppercase; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + border: 2px dashed var(--line); + border-radius: 1rem; + padding: 4rem 2rem; + text-align: center; +} + +.empty-state h2 { margin: 0 0 0.5rem; font-weight: 500; } +.empty-state p { color: var(--muted); margin: 0 0 1.5rem; } +.empty-actions { display: flex; gap: 0.75rem; justify-content: center; } + +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: 1.1rem; +} + +.skeleton-tile { + aspect-ratio: 16 / 9; + border-radius: 0.7rem; + background: var(--panel); +} + +.status-panel { + position: sticky; + top: 1.5rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 0.9rem; + overflow: hidden; +} + +.preview-frame { background: #000; } +.status-rows { padding: 0.9rem 1rem; display: grid; gap: 0.75rem; } + +.status-row { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0.5rem 0.6rem; +} + +.status-dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--bad); +} + +.status-dot[data-online="true"] { background: var(--ok); } +.status-label { font-weight: 500; font-size: 0.9rem; } +.status-detail { + grid-column: 1 / -1; + color: var(--muted); + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-queue { + position: fixed; + right: 1.25rem; + bottom: 1.25rem; + width: 22rem; + max-height: 40vh; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 0.8rem; + padding: 0.75rem 0.9rem; +} + +.upload-queue header { display: flex; justify-content: space-between; align-items: center; } +.upload-queue h2 { margin: 0; font-size: 0.85rem; font-weight: 500; } +.upload-queue ul { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.5rem; } +.upload-queue li { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem 0.5rem; font-size: 0.78rem; } +.upload-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.upload-state { color: var(--muted); } +.upload-state[data-state="complete"] { color: var(--ok); } +.upload-state[data-state="failed"] { color: var(--bad); } +.upload-state[data-state="duplicate"], +.upload-state[data-state="already_in_playlist"] { color: var(--warn); } +.upload-message { grid-column: 1 / -1; color: var(--muted); } +.upload-bar { grid-column: 1 / -1; height: 3px; background: var(--line); border-radius: 2px; } +.upload-bar span { display: block; height: 100%; background: var(--accent); border-radius: 2px; } + +.library-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 30rem; + background: var(--panel); + border-left: 1px solid var(--line); + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + z-index: 20; +} + +.library-panel header { display: flex; justify-content: space-between; align-items: center; } +.library-panel h2 { margin: 0; font-size: 1rem; font-weight: 500; } + +.library-search { + font: inherit; + padding: 0.5rem 0.7rem; + border-radius: 0.5rem; + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); +} + +.library-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.75rem; + overflow: auto; + flex: 1; +} + +.library-grid button { + width: 100%; + padding: 0; + border: 2px solid transparent; + border-radius: 0.6rem; + background: var(--surface); + color: var(--text); + cursor: pointer; + overflow: hidden; + display: grid; + gap: 0.25rem; +} + +.library-grid li[data-selected="true"] button { border-color: var(--accent); } +.library-grid button[disabled] { opacity: 0.55; cursor: default; } +.library-grid img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; } +.library-image-missing { width: 100%; aspect-ratio: 16 / 9; background: #22303c; display: block; } +.library-name { + font-size: 0.72rem; + padding: 0 0.4rem; + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.library-tag { font-size: 0.65rem; color: var(--muted); padding: 0 0.4rem 0.4rem; } +.library-tag.warn { color: var(--bad); } +.library-tag[data-expired="true"] { color: var(--bad); } + +.toasts { + position: fixed; + left: 50%; + bottom: 1.5rem; + transform: translateX(-50%); + display: grid; + gap: 0.5rem; + z-index: 30; +} + +.toast { + display: flex; + gap: 1rem; + align-items: center; + background: var(--panel); + border: 1px solid var(--line); + border-left: 3px solid var(--muted); + border-radius: 0.5rem; + padding: 0.6rem 0.9rem; + font-size: 0.85rem; +} + +.toast[data-tone="error"] { border-left-color: var(--bad); } +.toast[data-tone="success"] { border-left-color: var(--ok); } +.toast button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; } + +.drop-overlay { + position: fixed; + inset: 0; + background: rgba(15, 20, 25, 0.75); + border: 3px dashed var(--accent); + display: grid; + place-items: center; + font-size: 1.1rem; + z-index: 40; + pointer-events: none; +} + +.mobile-splash { + min-height: 100vh; + display: grid; + place-content: center; + gap: 0.75rem; + padding: 2rem; + text-align: center; + background: #0f1419; + color: #eef2f6; + font-family: "Roboto", system-ui, sans-serif; +} + +.mobile-splash h1 { margin: 0; font-weight: 500; } +.mobile-splash .muted { color: #93a1b0; font-size: 0.9rem; max-width: 28rem; } diff --git a/client/src/dashboard/entryStatus.js b/client/src/dashboard/entryStatus.js new file mode 100644 index 0000000..15bea09 --- /dev/null +++ b/client/src/dashboard/entryStatus.js @@ -0,0 +1,38 @@ +// Ring semantics for a playlist entry. Status is never conveyed by colour +// alone: every tile also carries this text for hover, focus, and assistive +// technology. +function formatInstant(value) { + try { + return new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + return "a scheduled time"; + } +} + +export function entryStatus(entry) { + if (!entry.enabled) return { ring: "disabled", label: "Disabled" }; + const reason = entry.notPlayableReason; + if (reason === "asset_not_ready" || reason === "asset_expired") { + return { + ring: "error", + label: reason === "asset_expired" ? "Asset expired" : "Asset unavailable", + }; + } + if (reason === "scheduled_future") { + return { + ring: "scheduled", + label: entry.scheduleStartAt + ? `Scheduled — available ${formatInstant(entry.scheduleStartAt)}` + : "Scheduled — not yet available", + }; + } + if (reason === "scheduled_ended") { + return { ring: "scheduled", label: "Scheduled period ended" }; + } + return { ring: "playable", label: "Enabled" }; +} diff --git a/client/src/display/ScenePreview.jsx b/client/src/display/ScenePreview.jsx new file mode 100644 index 0000000..532f0bd --- /dev/null +++ b/client/src/display/ScenePreview.jsx @@ -0,0 +1,76 @@ +import { resolveScenePresentation } from "./presentation.js"; +import "./scene.css"; + +// The dashboard's live preview and the kiosk renderer must never drift apart. +// This component reuses the same presentation resolver and the same scene CSS +// the signage output uses; only the container is different. +export default function ScenePreview({ snapshot, className = "" }) { + const target = snapshot?.staticTarget || snapshot?.normalTarget || null; + const mediaUrl = target?.asset?.media?.url ?? null; + const presentation = target ? resolveScenePresentation(target) : null; + const modeOff = snapshot?.mode === "off"; + + return ( +
    + {presentation && mediaUrl ? ( +
    +
    + +
    +
    + +
    + ) : null} + {/* Mode off is the same topmost black gate the kiosk applies. */} +
    +
    + ); +} diff --git a/client/src/display/display.css b/client/src/display/display.css index 4ec5c89..ed1c6bf 100644 --- a/client/src/display/display.css +++ b/client/src/display/display.css @@ -1,3 +1,5 @@ +@import "./scene.css"; + /* Signage-only styling. These fullscreen constraints are scoped to the display route so the future management dashboard is unaffected. */ body[data-display-route="true"] { @@ -21,53 +23,6 @@ body[data-display-route="true"] { outline: none; } -/* Two permanent layers. Only opacity and content change; they are never - recreated, and an outgoing layer is never faded out. */ -.scene { - position: absolute; - inset: 0; - opacity: 0; - background: #000; - transition-property: opacity; - transition-timing-function: linear; - transition-duration: 0ms; - will-change: opacity; -} - -.scene-background { - position: absolute; - inset: 0; - background-color: #000; - overflow: hidden; -} - -/* Overscanned so the blur never reveals a transparent or black edge. */ -.scene-background-image { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - object-position: center; - transform: scale(1.08); - display: none; -} - -.scene-darken { - position: absolute; - inset: 0; - background: #000; - opacity: 0; -} - -.scene-foreground { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-position: center; -} - /* Topmost black gate for mode off. It covers the output instantly while the renderer keeps tracking the authoritative timeline underneath. */ .output-gate { diff --git a/client/src/display/scene.css b/client/src/display/scene.css new file mode 100644 index 0000000..e819a4c --- /dev/null +++ b/client/src/display/scene.css @@ -0,0 +1,62 @@ +/* Shared scene presentation. + Both the kiosk renderer and the dashboard's live preview import this, so a + slide cannot look one way on the signage output and another way in + management. */ +/* Two permanent layers. Only opacity and content change; they are never + recreated, and an outgoing layer is never faded out. */ +.scene { + position: absolute; + inset: 0; + opacity: 0; + background: #000; + transition-property: opacity; + transition-timing-function: linear; + transition-duration: 0ms; + will-change: opacity; +} + +.scene-background { + position: absolute; + inset: 0; + background-color: #000; + overflow: hidden; +} + +/* Overscanned so the blur never reveals a transparent or black edge. */ +.scene-background-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + transform: scale(1.08); + display: none; +} + +.scene-darken { + position: absolute; + inset: 0; + background: #000; + opacity: 0; +} + +.scene-foreground { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-position: center; +} + + +/* A preview renders the same scene inside a small box. Blur is expressed in + pixels, so it is scaled down with the container to keep the preview + visually representative of the full-size output. */ +.scene-preview { + position: relative; + overflow: hidden; + background: #000; + aspect-ratio: 16 / 9; + width: 100%; +} diff --git a/client/src/main.jsx b/client/src/main.jsx index c3cb3d2..9e838cf 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,7 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import App from "./App.jsx"; +import DashboardApp from "./dashboard/DashboardApp.jsx"; import DisplayApp from "./display/DisplayApp.jsx"; const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display"; @@ -18,5 +18,5 @@ if (isDisplayRoute && "serviceWorker" in navigator) { } createRoot(document.getElementById("root")).render( - {isDisplayRoute ? : }, + {isDisplayRoute ? : }, ); diff --git a/docker-compose.yaml b/docker-compose.yaml index 6b2780e..10957a7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,7 +13,13 @@ services: api: condition: service_healthy networks: - - app + app: + # Only the web service is reachable from the shared reverse-proxy + # network. Caddy resolves it as announcements-web:80; the API and object + # storage stay private on the Announcements networks. + tabernet: + aliases: + - announcements-web healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] interval: 15s @@ -110,6 +116,8 @@ networks: app: storage: internal: true + tabernet: + external: true volumes: app-data: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3051183..bb20c3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,18 @@ importers: client: dependencies: + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/sortable': + specifier: 10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.8) + '@fontsource/roboto': + specifier: 5.2.5 + version: 5.2.5 react: specifier: ^19.2.6 version: 19.2.8 @@ -223,6 +235,28 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -265,6 +299,9 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fontsource/roboto@5.2.5': + resolution: {integrity: sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1918,6 +1955,31 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -1957,6 +2019,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@fontsource/roboto@5.2.5': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0