diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 85d35eaf..26d12686 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -5964,15 +5964,19 @@ test("keeps a list of radio stations, replacing rather than patching", async ({ page.getByRole("heading", { name: "Radio", exact: true }), ).toBeVisible(); await expect( - page.getByText("No stations yet. Add one above."), + page.getByText("No stations yet. Add one to keep it here."), ).toBeVisible(); - await page.getByLabel("Station name").fill("Radio Nova"); - await page.getByLabel("Stream address").fill("https://nova.example.org/live"); - await page.getByLabel("Station page (optional)").fill("https://nova.example.org/"); + // The form lives in a dialog the page's own button opens. await page.getByRole("button", { name: "Add station" }).click(); - - const listed = page.locator(".resource-list li"); + const form = page.getByRole("dialog", { name: "Add a station" }); + await form.getByLabel("Station name").fill("Radio Nova"); + await form.getByLabel("Stream address").fill("https://nova.example.org/live"); + await form.getByLabel("Station page (optional)").fill("https://nova.example.org/"); + await form.getByRole("button", { name: "Save station" }).click(); + await expect(form).toHaveCount(0); + + const listed = page.locator(".station-grid li"); await expect(listed).toHaveCount(1); await expect(listed.first()).toContainText("Radio Nova"); await expect(listed.first()).toContainText("https://nova.example.org/live"); @@ -5982,41 +5986,53 @@ test("keeps a list of radio stations, replacing rather than patching", async ({ // An address the server would refuse is refused here first, with a reason, // and the button stays shut rather than sending a request to find out. - await page.getByLabel("Station name").fill("Elsewhere"); - await page.getByLabel("Stream address").fill("mms://elsewhere.example.org/s"); + await page.getByRole("button", { name: "Add station" }).click(); + await form.getByLabel("Station name").fill("Elsewhere"); + await form.getByLabel("Stream address").fill("mms://elsewhere.example.org/s"); await expect( - page.getByText("An address must start with http:// or https://."), + form.getByText("An address must start with http:// or https://."), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Add station" }), + form.getByRole("button", { name: "Save station" }), ).toBeDisabled(); await expectNoWcagViolations(page); - - // Editing loads the station into the same form, and says what saving means: - // the page address is cleared because the form sends every field. - await page.getByRole("button", { name: "Edit" }).click(); - await expect(page.getByLabel("Station name")).toHaveValue("Radio Nova"); - await expect(page.getByLabel("Station page (optional)")).toHaveValue( + await form.getByRole("button", { name: "Cancel" }).click(); + await expect(form).toHaveCount(0); + + // Editing, from the card's menu, loads the station into the same form, and + // says what saving means: the page address is cleared because the form + // sends every field. + await listed.first().getByRole("button", { name: "Actions for Radio Nova" }).click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + const editor = page.getByRole("dialog", { name: "Edit “Radio Nova”" }); + await expect(editor.getByLabel("Station name")).toHaveValue("Radio Nova"); + await expect(editor.getByLabel("Station page (optional)")).toHaveValue( "https://nova.example.org/", ); await expect( - page.getByText( + editor.getByText( "Saving replaces every field, so an empty page address clears the stored one.", ), ).toBeVisible(); - await page.getByLabel("Station name").fill("Radio Nova Classics"); - await page.getByLabel("Station page (optional)").fill(""); - await page.getByRole("button", { name: "Save station" }).click(); + await editor.getByLabel("Station name").fill("Radio Nova Classics"); + await editor.getByLabel("Station page (optional)").fill(""); + await editor.getByRole("button", { name: "Save station" }).click(); await expect(listed.first()).toContainText("Radio Nova Classics"); // Cleared, which is the half a patch would have got wrong. await expect(page.getByRole("link", { name: "Station page" })).toHaveCount(0); - await page.getByRole("button", { name: "Delete" }).click(); + // A delete is confirmed first, then goes. + page.once("dialog", (question) => void question.accept()); + await listed + .first() + .getByRole("button", { name: "Actions for Radio Nova Classics" }) + .click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); await expect(listed).toHaveCount(0); await expect( - page.getByText("No stations yet. Add one above."), + page.getByText("No stations yet. Add one to keep it here."), ).toBeVisible(); }); @@ -6036,10 +6052,12 @@ test("closes the form while a station is saving, and leaves listening alone", as page, }) => { await page.goto("/radio"); - await page.getByLabel("Station name").fill("Radio Nova"); - await page.getByLabel("Stream address").fill("https://nova.example.org/live"); + const form = page.getByRole("dialog", { name: "Add a station" }); await page.getByRole("button", { name: "Add station" }).click(); - await expect(page.locator(".resource-list li")).toHaveCount(1); + await form.getByLabel("Station name").fill("Radio Nova"); + await form.getByLabel("Stream address").fill("https://nova.example.org/live"); + await form.getByRole("button", { name: "Save station" }).click(); + await expect(page.locator(".station-grid li")).toHaveCount(1); expect(radioWrites).toEqual(["POST"]); // Held from here on, so only the second save waits. @@ -6048,9 +6066,10 @@ test("closes the form while a station is saving, and leaves listening alone", as release = resolve; }); - await page.getByLabel("Station name").fill("FIP"); - await page.getByLabel("Stream address").fill("https://fip.example.org/live"); await page.getByRole("button", { name: "Add station" }).click(); + await form.getByLabel("Station name").fill("FIP"); + await form.getByLabel("Stream address").fill("https://fip.example.org/live"); + await form.getByRole("button", { name: "Save station" }).click(); // Shut for the whole round trip: the button cannot be pressed again, and the // fields cannot be refilled under a save that is about to clear them. @@ -6061,16 +6080,20 @@ test("closes the form while a station is saving, and leaves listening alone", as // the fields and disabled the button for the other reason. Held open, the // save cannot land, and removing the guard fails on this very line — // measured both ways. - await expect(page.getByRole("button", { name: "Add station" })).toBeDisabled(); - await expect(page.getByLabel("Station name")).toBeDisabled(); - await expect(page.getByLabel("Stream address")).toBeDisabled(); - // And no edit can begin, which is what made a save clear a form somebody - // else had just filled. - await expect(page.getByRole("button", { name: "Edit" })).toBeDisabled(); - await expect(page.getByRole("button", { name: "Delete" })).toBeDisabled(); + await expect(form.getByRole("button", { name: "Save station" })).toBeDisabled(); + await expect(form.getByLabel("Station name")).toBeDisabled(); + await expect(form.getByLabel("Stream address")).toBeDisabled(); + await expect(form.getByRole("button", { name: "Cancel" })).toBeDisabled(); + // And no edit or delete can begin, which is what made a save clear a form + // somebody else had just filled: the cards' menus are shut as well. + await expect( + page.locator(".station-grid li").first().locator(".action-menu-button"), + ).toBeDisabled(); // Listening is the deliberate exception: it writes nothing, and a reader // waiting on a save has no reason to lose the station they are hearing. - await expect(page.getByRole("button", { name: "Listen" })).toBeEnabled(); + await expect( + page.locator(".station-grid li").first().locator(".station-play"), + ).toBeEnabled(); // The second click of an impatient double-click, dispatched for real rather // than waited for: `force` skips Playwright's own actionability check, so @@ -6078,18 +6101,20 @@ test("closes the form while a station is saving, and leaves listening alone", as // Without the guard this click sends a second POST and the account ends up // with two identical stations from one gesture — which is what the count // below would then show. - await page - .getByRole("button", { name: "Add station" }) + await form + .getByRole("button", { name: "Save station" }) .click({ force: true }); release(); - await expect(page.locator(".resource-list li")).toHaveCount(2); + await expect(page.locator(".station-grid li")).toHaveCount(2); // One gesture, one station. Two POSTs in all, one per click. expect(radioWrites).toEqual(["POST", "POST"]); - // Open again, and empty, once the save has landed. - await expect(page.getByLabel("Station name")).toBeEnabled(); - await expect(page.getByLabel("Station name")).toHaveValue(""); + // Closed once the save has landed, and empty when it is opened again. + await expect(form).toHaveCount(0); + await page.getByRole("button", { name: "Add station" }).click(); + await expect(form.getByLabel("Station name")).toBeEnabled(); + await expect(form.getByLabel("Station name")).toHaveValue(""); }); /** @@ -6238,7 +6263,7 @@ test("carries a live station in the bar across pages, off the ReplayGain graph", await expect.poll(async () => (await elements(page)).queue?.claimed).toBe(true); await navigateWithin(page, "Radio"); - const station = page.locator(".resource-list li").filter({ hasText: "Radio Nova" }); + const station = page.locator(".station-grid li").filter({ hasText: "Radio Nova" }); await station.getByRole("button", { name: "Listen" }).click(); await expect(bar.locator(".player-track strong")).toHaveText("Radio Nova"); @@ -6346,35 +6371,39 @@ test("takes an image address for a station, and shows the image the server kept" route.fulfill({ contentType: "image/png", body: PIXEL }), ); await page.goto("/radio"); - await page.getByLabel("Station name").fill("Radio Nova"); - await page.getByLabel("Stream address").fill(NOVA); + await page.getByRole("button", { name: "Add station" }).click(); + const form = page.getByRole("dialog", { name: "Add a station" }); + await form.getByLabel("Station name").fill("Radio Nova"); + await form.getByLabel("Stream address").fill(NOVA); // Refused here first, as the server would, and the button stays shut. - await page.getByLabel("Station image (optional)").fill("ftp://logos.example.org/n.png"); + await form.getByLabel("Station image (optional)").fill("ftp://logos.example.org/n.png"); await expect( - page.getByText("An address must start with http:// or https://."), + form.getByText("An address must start with http:// or https://."), ).toBeVisible(); - await expect(page.getByRole("button", { name: "Add station" })).toBeDisabled(); - await page + await expect(form.getByRole("button", { name: "Save station" })).toBeDisabled(); + await form .getByLabel("Station image (optional)") .fill("https://logos.example.org/nova.png"); await expect( - page.getByText("The server downloads it once, when you save", { exact: false }), + form.getByText("The server downloads it once, when you save", { exact: false }), ).toBeVisible(); - await page.getByRole("button", { name: "Add station" }).click(); + await form.getByRole("button", { name: "Save station" }).click(); - const listed = page.locator(".resource-list li").filter({ hasText: "Radio Nova" }); - await expect(listed.locator("img.resource-cover")).toBeVisible(); + const listed = page.locator(".station-grid li").filter({ hasText: "Radio Nova" }); + await expect(listed.locator("img.station-cover")).toBeVisible(); expect(stationImages).toEqual(["https://logos.example.org/nova.png"]); // Edited, the form shows the address back and sends it again; cleared, it // sends `null` and the station keeps the symbol. - await listed.getByRole("button", { name: "Edit" }).click(); - await expect(page.getByLabel("Station image (optional)")).toHaveValue( + await listed.getByRole("button", { name: "Actions for Radio Nova" }).click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + const editor = page.getByRole("dialog", { name: "Edit “Radio Nova”" }); + await expect(editor.getByLabel("Station image (optional)")).toHaveValue( "https://logos.example.org/nova.png", ); - await page.getByLabel("Station image (optional)").fill(""); - await page.getByRole("button", { name: "Save station" }).click(); - await expect(listed.locator(".resource-cover.cover-fallback svg")).toBeVisible(); + await editor.getByLabel("Station image (optional)").fill(""); + await editor.getByRole("button", { name: "Save station" }).click(); + await expect(listed.locator(".station-cover.cover-fallback svg")).toBeVisible(); expect(stationImages).toEqual(["https://logos.example.org/nova.png", null]); await expectNoWcagViolations(page); expect(asked, "the browser never asks the image's own host").toEqual([]); @@ -6400,8 +6429,8 @@ for (const theme of ["dark", "light"] as const) { return route.fulfill({ contentType: "image/png", body: PIXEL }); }); await page.goto("/radio"); - const nova = page.locator(".resource-list li").filter({ hasText: "Radio Nova" }); - await expect(nova.locator("img.resource-cover")).toBeVisible(); + const nova = page.locator(".station-grid li").filter({ hasText: "Radio Nova" }); + await expect(nova.locator("img.station-cover")).toBeVisible(); await nova.getByRole("button", { name: "Listen" }).click(); const bar = page.locator(".player"); @@ -6419,7 +6448,7 @@ for (const theme of ["dark", "light"] as const) { await expectNoWcagViolations(page); // A station without an image keeps the radio symbol. - const plain = page.locator(".resource-list li").filter({ hasText: "Radio Plain" }); + const plain = page.locator(".station-grid li").filter({ hasText: "Radio Plain" }); await plain.getByRole("button", { name: "Listen" }).click(); await expect(bar.locator(".player-track strong")).toHaveText("Radio Plain"); await expect(bar.locator(".player-cover.cover-fallback svg")).toBeVisible(); @@ -6635,10 +6664,11 @@ test("files tracks into folders from the menu and by dragging", async ({ await page.goto("/folders"); await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); - const cards = page.getByRole("list", { name: "Folders" }); - await expect(cards.getByRole("link", { name: /To sort/ })).toContainText("3 tracks"); - // WaveFlow's list, not a folder of the owner's, and it says so. - await expect(cards.getByRole("link", { name: /To sort/ })).toContainText("Automatic"); + // WaveFlow's list, not a folder of the owner's: kept apart from them, and + // it says so. + const kept = page.getByRole("list", { name: "Kept by WaveFlow" }); + await expect(kept.getByRole("link", { name: /To sort/ })).toContainText("3 tracks"); + await expect(kept.getByRole("link", { name: /To sort/ })).toContainText("Automatic"); // A folder, and a second one refused for a name already taken. await page.getByRole("button", { name: "New folder" }).click(); @@ -6654,7 +6684,7 @@ test("files tracks into folders from the menu and by dragging", async ({ await page.getByRole("button", { name: "Cancel" }).click(); // From the menu: the unfiled tracks, the first one filed into Rock. - await cards.getByRole("link", { name: /To sort/ }).click(); + await kept.getByRole("link", { name: /To sort/ }).click(); await expect(page.getByRole("heading", { name: "To sort" })).toBeVisible(); const rows = page.locator(".songs tbody tr"); await expect(rows).toHaveCount(3); @@ -6712,7 +6742,7 @@ test("files tracks into folders from the menu and by dragging", async ({ await expect(page.getByRole("status")).toHaveText( "Folder deleted: 2 tracks are back in To sort.", ); - await expect(cards.getByRole("link", { name: /To sort/ })).toContainText("3 tracks"); + await expect(kept.getByRole("link", { name: /To sort/ })).toContainText("3 tracks"); await expectNoWcagViolations(page); }); @@ -6953,7 +6983,10 @@ test("closes a folder's form when another folder is opened", async ({ page }) => await page.goto("/folders"); await page.getByRole("button", { name: "New folder" }).click(); await expect(page.getByLabel("Folder name")).toBeVisible(); - await page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /To sort/ }).click(); + await page + .getByRole("list", { name: "Kept by WaveFlow" }) + .getByRole("link", { name: /To sort/ }) + .click(); await expect(page.getByRole("heading", { name: "To sort" })).toBeVisible(); await expect(page.getByLabel("Folder name")).toHaveCount(0); expect(state.writes).toEqual([]); @@ -7601,7 +7634,7 @@ test("keeps the folders and the folder picker readable in the light", async ({ await serveFiledFolders(page, [song(1, "Hidden Place", 0, false)]); await chooseLightTheme(page); await page.goto("/folders"); - const cards = page.getByRole("list", { name: "Folders" }); + const cards = page.getByRole("list", { name: "Kept by WaveFlow" }); await expect(cards.getByRole("link", { name: /To sort/ })).toBeVisible(); await expectNoWcagViolations(page); diff --git a/webapp/e2e/workspace-pages.spec.ts b/webapp/e2e/workspace-pages.spec.ts new file mode 100644 index 00000000..8175a533 --- /dev/null +++ b/webapp/e2e/workspace-pages.spec.ts @@ -0,0 +1,683 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, type Page, test } from "@playwright/test"; + +/** + * The workspace pages: Radio, Podcasts, Folders and the Maintenance hub, which + * manage something rather than present it. + * + * The radio mock keeps what it is sent, as the one in `studio-nocturne` does: + * a delete that deleted nothing, or an edit that saved nothing, would + * otherwise pass against a fixed answer. + */ + +const session = { + access_token: "e2e-access-token", + user: { id: "user-1", username: "listener", role: "admin" }, + device_id: "device-1", +}; + +const LIBRARY_ID = "2740f393-e84e-488a-8a67-745deb23399a"; + +type Station = { + id: string; + name: string; + stream_url: string; + homepage_url: string | null; + image_url: string | null; + artwork_hash: string | null; + created_at: number; + updated_at: number; +}; + +let stations: Station[] = []; +/** Every radio write, by method and station, so one click can be told from two. */ +let radioWrites: string[] = []; +/** Held open by the test that needs a save in flight; instant otherwise. */ +let radioGate: Promise = Promise.resolve(); +let canManage = true; + +type Episode = { + id: string; + channel_id: string; + channel_title: string; + artwork_hash: string | null; + title: string; + description: string | null; + published_at: number | null; + duration_ms: number | null; + status: "new" | "downloading" | "completed" | "error" | "deleted"; + error: string | null; + size: number | null; + suffix: string | null; + content_type: string | null; + position_ms: number | null; + created_at: number; +}; + +const episode = (channel: string, id: string, title: string): Episode => ({ + id, + channel_id: channel, + channel_title: channel, + artwork_hash: null, + title, + description: null, + published_at: 1_704_448_800_000, + duration_ms: 720_000, + status: "new", + error: null, + size: null, + suffix: null, + content_type: null, + position_ms: null, + created_at: 1_704_448_800_000, +}); + +/** A channel as `GET /podcasts/{id}` answers it; listed without its episodes. */ +const channelOf = (id: string, title: string, episodes: Episode[]) => ({ + id, + url: null, + title, + description: null, + site_url: null, + image_url: null, + artwork_hash: null, + status: "completed", + error: null, + created_at: 1, + refreshed_at: 1, + episode_count: episodes.length, + episodes, +}); + +let channels: Array> = []; +let folders: Array<{ + id: string; + parent_id: string | null; + name: string; + track_count: number; +}> = []; + +async function expectNoWcagViolations(page: Page) { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter( + (animation) => + animation.effect?.getComputedTiming().endTime !== Infinity, + ) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); +} + +function stationFrom(id: string, draft: Record): Station { + return { + id, + name: String(draft.name).trim(), + stream_url: String(draft.stream_url), + homepage_url: (draft.homepage_url as string | null) ?? null, + image_url: (draft.image_url as string | null) ?? null, + artwork_hash: null, + created_at: 1_700_000_000_000, + updated_at: 1_700_000_000_000, + }; +} + +async function mockApi(page: Page) { + await page.context().addCookies([ + { name: "waveflow-csrf", value: "e2e-csrf", url: "http://127.0.0.1:4173" }, + ]); + await page.route("**/api/v2/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const path = url.pathname; + if (path === "/api/v2/web/auth/refresh") { + await route.fulfill({ json: session }); + return; + } + if (path === "/api/v2/playlists" || path === "/api/v2/albums") { + await route.fulfill({ json: [] }); + return; + } + if (path === "/api/v2/libraries") { + await route.fulfill({ + json: [ + { + id: LIBRARY_ID, + name: "Ma musique", + visibility: "private", + role: "owner", + last_scan_started_at: null, + last_scan_completed_at: null, + accepts_uploads: false, + accepts_canvas: false, + follow_links: false, + }, + ], + }); + return; + } + if (path === "/api/v2/radio-stations") { + if (method === "POST") { + radioWrites.push("POST"); + await radioGate; + const created = stationFrom( + `station-${stations.length + 1}`, + request.postDataJSON(), + ); + stations = [...stations, created]; + await route.fulfill({ status: 201, json: created }); + return; + } + await route.fulfill({ json: stations }); + return; + } + if (path.startsWith("/api/v2/radio-stations/")) { + const id = path.slice("/api/v2/radio-stations/".length); + radioWrites.push(`${method} ${id}`); + if (method === "DELETE") { + stations = stations.filter((station) => station.id !== id); + await route.fulfill({ status: 204, body: "" }); + return; + } + const replaced = stationFrom(id, request.postDataJSON()); + stations = stations.map((station) => + station.id === id ? replaced : station, + ); + await route.fulfill({ json: replaced }); + return; + } + if (path === "/api/v2/podcasts") { + await route.fulfill({ + json: { + enabled: true, + can_manage: canManage, + channels: channels.map(({ episodes: _episodes, ...listed }) => listed), + }, + }); + return; + } + if (path.startsWith("/api/v2/podcasts/")) { + const id = path.slice("/api/v2/podcasts/".length); + const found = channels.find((channel) => channel.id === id); + await route.fulfill( + found ? { json: found } : { status: 404, json: { code: "not_found" } }, + ); + return; + } + if (path === `/api/v2/libraries/${LIBRARY_ID}/folders`) { + await route.fulfill({ json: { folders, unfiled_track_count: 12 } }); + return; + } + if (path === `/api/v2/libraries/${LIBRARY_ID}/quality`) { + await route.fulfill({ + json: { + enabled: true, + lossless: 0, + measured: 0, + verdicts: [], + analysis: { + state: "idle", + in_flight: 0, + pass_started_at: null, + last_measured_at: null, + }, + }, + }); + return; + } + if (path.startsWith(`/api/v2/libraries/${LIBRARY_ID}/quality/tracks`)) { + await route.fulfill({ json: [] }); + return; + } + await route.fulfill({ status: 404, json: { error: "not found" } }); + }); +} + +test.beforeEach(async ({ page }) => { + stations = []; + radioWrites = []; + radioGate = Promise.resolve(); + canManage = true; + channels = []; + folders = []; + await mockApi(page); +}); + +/** + * A station is added in a dialog, listed as a card, and edited or deleted + * from the card's "⋯" menu — a delete only once it is confirmed. + */ +test("adds a station in a dialog, and edits and deletes it from its card's menu", async ({ + page, +}) => { + await page.goto("/radio"); + await expect(page.getByRole("heading", { name: "Radio", exact: true })).toBeVisible(); + await expect(page.getByText("No stations yet. Add one to keep it here.")).toBeVisible(); + + const opener = page.getByRole("button", { name: "Add station" }); + await opener.click(); + const dialog = page.getByRole("dialog", { name: "Add a station" }); + await expect(dialog).toBeVisible(); + // The form's rules came with it into the dialog. + await dialog.getByLabel("Station name").fill("Radio Nova"); + await dialog.getByLabel("Stream address").fill("mms://nova.example.org/live"); + await expect( + dialog.getByText("An address must start with http:// or https://."), + ).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Save station" })).toBeDisabled(); + await dialog.getByLabel("Stream address").fill("https://nova.example.org/live"); + await dialog.getByLabel("Station page (optional)").fill("https://nova.example.org/"); + await expect(dialog.getByLabel("Station image (optional)")).toBeVisible(); + await expectNoWcagViolations(page); + await dialog.getByRole("button", { name: "Save station" }).click(); + + await expect(dialog).toHaveCount(0); + expect(radioWrites).toEqual(["POST"]); + // Focus comes back to what opened the dialog. + await expect(opener).toBeFocused(); + const grid = page.getByRole("list", { name: "Stations" }); + const card = grid.getByRole("listitem").filter({ hasText: "Radio Nova" }); + await expect(card).toHaveCount(1); + await expect(card.getByRole("button", { name: "Listen to Radio Nova" })).toBeVisible(); + await expect(card.getByRole("link", { name: "Station page" })).toHaveAttribute( + "href", + "https://nova.example.org/", + ); + // No delete button sits on the card any more: it is in the menu. + await expect(card.getByRole("button", { name: "Delete" })).toHaveCount(0); + await expectNoWcagViolations(page); + + // The menu, by keyboard: it opens on its first entry, the arrows move + // through it, and Escape gives focus back to the button. + const more = card.getByRole("button", { name: "Actions for Radio Nova" }); + await more.click(); + const menu = page.getByRole("menu", { name: "Actions for Radio Nova" }); + await expect(menu.getByRole("menuitem")).toHaveText(["Edit", "Delete"]); + await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeFocused(); + await expectNoWcagViolations(page); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(more).toBeFocused(); + + // Editing opens the same form, filled, under the station's name. + await more.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + const editor = page.getByRole("dialog", { name: "Edit “Radio Nova”" }); + await expect(editor.getByLabel("Station name")).toHaveValue("Radio Nova"); + await expect( + editor.getByText("Saving replaces every field", { exact: false }), + ).toBeVisible(); + await editor.getByLabel("Station name").fill("Radio Nova Classics"); + await editor.getByRole("button", { name: "Save station" }).click(); + await expect(editor).toHaveCount(0); + await expect(grid.getByRole("listitem")).toHaveText([/Radio Nova Classics/]); + expect(radioWrites).toEqual(["POST", "PUT station-1"]); + + // A delete is asked first; declined, nothing is sent. + const renamed = grid.getByRole("listitem").filter({ hasText: "Radio Nova Classics" }); + page.once("dialog", (question) => { + expect(question.message()).toContain("Radio Nova Classics"); + void question.dismiss(); + }); + await renamed.getByRole("button", { name: "Actions for Radio Nova Classics" }).click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); + await expect(grid.getByRole("listitem")).toHaveCount(1); + expect(radioWrites).toEqual(["POST", "PUT station-1"]); + + // Confirmed, it goes. + page.once("dialog", (question) => void question.accept()); + await renamed.getByRole("button", { name: "Actions for Radio Nova Classics" }).click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); + await expect(page.getByText("No stations yet. Add one to keep it here.")).toBeVisible(); + expect(radioWrites).toEqual(["POST", "PUT station-1", "DELETE station-1"]); +}); + +/** + * The dialog cannot be put away while it saves: Escape would otherwise close + * the form under a request that is about to report into it. + */ +test("holds the station dialog open while its save is in flight", async ({ page }) => { + let release = () => {}; + radioGate = new Promise((resolve) => { + release = resolve; + }); + await page.goto("/radio"); + await page.getByRole("button", { name: "Add station" }).click(); + const dialog = page.getByRole("dialog", { name: "Add a station" }); + await dialog.getByLabel("Station name").fill("FIP"); + await dialog.getByLabel("Stream address").fill("https://fip.example.org/live"); + await dialog.getByRole("button", { name: "Save station" }).click(); + await expect.poll(() => radioWrites).toEqual(["POST"]); + + await expect(dialog.getByLabel("Station name")).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Close" })).toBeDisabled(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + + release(); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("list", { name: "Stations" }).getByRole("listitem"), + ).toHaveText([/FIP/]); +}); + +/** + * An empty podcasts page says what podcasts do on this server, to everyone; + * a manager is also given the first step, which is the feed field. + */ +test("explains an empty podcasts page, and offers a manager the first step", async ({ + page, +}) => { + await page.goto("/podcasts"); + const empty = page.locator(".empty-state"); + await expect(empty).toContainText("No podcasts yet."); + await expect(empty).toContainText( + "An administrator subscribes to a show’s feed here. The server downloads its episodes, and every account on this server can listen to them.", + ); + await expect(empty.locator("svg")).toBeVisible(); + await expectNoWcagViolations(page); + await empty.getByRole("button", { name: "Add your first podcast" }).click(); + await expect(page.getByLabel("Feed address")).toBeFocused(); +}); + +test("explains an empty podcasts page to a reader, with nothing to press", async ({ + page, +}) => { + canManage = false; + await page.goto("/podcasts"); + const empty = page.locator(".empty-state"); + await expect(empty).toContainText("every account on this server can listen to them"); + await expect(empty.getByRole("button")).toHaveCount(0); + await expect(page.getByLabel("Feed address")).toHaveCount(0); + await expectNoWcagViolations(page); +}); + +/** + * "To sort" is WaveFlow's, not a folder anyone made, and the header counts + * only the folders that were made: so it is drawn apart from them, under a + * heading of its own that says what it is. + */ +test("draws To sort apart from the folders the header counts", async ({ page }) => { + folders = [{ id: "folder-1", parent_id: null, name: "Jazz", track_count: 4 }]; + await page.goto("/folders"); + await expect(page.getByText("1 folder created in this library")).toBeVisible(); + const made = page.getByRole("list", { name: "Folders" }); + await expect(made.getByRole("link")).toHaveCount(1); + await expect(made.getByRole("link", { name: /Jazz/ })).toBeVisible(); + await expect(made.getByRole("link", { name: /To sort/ })).toHaveCount(0); + + const kept = page.getByRole("region", { name: "Kept by WaveFlow" }); + await expect(kept).toContainText( + "it is not counted among the folders above, and it cannot be renamed, moved or deleted", + ); + const tray = kept.getByRole("list", { name: "Kept by WaveFlow" }).getByRole("link", { + name: /To sort/, + }); + await expect(tray).toContainText("12 tracks"); + await expect(tray).toContainText("Automatic"); + await expectNoWcagViolations(page); + + await tray.click(); + await expect(page.getByRole("heading", { name: "To sort" })).toBeVisible(); +}); + +test("says no folder was made yet, and still offers To sort", async ({ page }) => { + await page.goto("/folders"); + await expect(page.getByText("0 folders created in this library")).toBeVisible(); + await expect( + page.getByText("No folder has been created in this library yet."), + ).toBeVisible(); + await expect(page.getByRole("list", { name: "Folders" })).toHaveCount(0); + await expect( + page.getByRole("list", { name: "Kept by WaveFlow" }).getByRole("link", { name: /To sort/ }), + ).toBeVisible(); +}); + +const WORKSPACE_PAGES = ["/radio", "/podcasts", "/folders", "/maintenance/quality"]; + +async function mainWidth(page: Page, path: string) { + await page.goto(path); + await expect(page.locator("main h2").first()).toBeVisible(); + return page.locator("main").evaluate((main) => main.getBoundingClientRect().width); +} + +/** + * On a wide window the workspace pages take more of it, and the editorial + * pages keep the contained column they were designed in. + */ +test("gives the workspace pages more of a wide window, and not the editorial ones", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "a wide window"); + await page.setViewportSize({ width: 2560, height: 1440 }); + stations = [stationFrom("station-1", { name: "Radio Nova", stream_url: "https://nova.example.org/live" })]; + const editorial = await mainWidth(page, "/"); + // 105rem, the contained column. + expect(editorial).toBe(1680); + for (const path of WORKSPACE_PAGES) { + expect(await mainWidth(page, path), path).toBeGreaterThan(editorial + 400); + } + await expectNoWcagViolations(page); +}); + +/** + * And on a phone, or beside the compact rail of a short window, they still + * fit: the 16 px gutter, and nothing to scroll sideways. + */ +test("keeps the workspace pages inside a phone and a short window", async ({ + page, +}, testInfo) => { + if (testInfo.project.name === "desktop") { + await page.setViewportSize({ width: 1024, height: 640 }); + } + stations = [ + stationFrom("station-1", { + name: "A station whose name is far too long to fit on one card", + stream_url: "https://a-very-long-host-name.example.org/a/very/long/stream/path.mp3", + homepage_url: "https://example.org/", + }), + stationFrom("station-2", { name: "FIP", stream_url: "https://fip.example.org/live" }), + ]; + folders = [{ id: "folder-1", parent_id: null, name: "Jazz", track_count: 4 }]; + for (const path of WORKSPACE_PAGES) { + await page.goto(path); + await expect(page.locator("main h2").first()).toBeVisible(); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow, path).toBe(0); + if (testInfo.project.name === "mobile") { + const gutter = await page + .locator("main") + .evaluate((main) => getComputedStyle(main).paddingLeft); + expect(gutter, path).toBe("16px"); + } + } + await page.goto("/radio"); + await expect(page.getByRole("list", { name: "Stations" }).getByRole("listitem")).toHaveCount(2); + await expectNoWcagViolations(page); +}); + +function twoShows() { + channels = [ + channelOf("channel-1", "Night Shift", [ + episode("channel-1", "episode-1", "First night"), + episode("channel-1", "episode-2", "Second night"), + ]), + channelOf("channel-2", "Day Shift", [ + episode("channel-2", "episode-9", "First day"), + ]), + ]; +} + +/** Whether the panel sits to the right of the list, their tops overlapping. */ +async function besideTheList(page: Page) { + const list = await page.getByRole("list", { name: "Shows" }).boundingBox(); + const panel = await page.locator("#podcast-panel").boundingBox(); + if (!list || !panel) throw new Error("both panels should be drawn"); + return { + right: panel.x >= list.x + list.width - 1, + overlap: panel.y < list.y + list.height && list.y < panel.y + panel.height, + below: panel.y >= list.y + list.height - 1, + }; +} + +/** + * From 1024 px of window the episodes sit beside the shows, and the panel is + * never an empty hole: with no show named it opens the first. Choosing + * another show swaps the panel, keeps focus on the button that chose it, says + * so to a screen reader, and lands in the address, so Back and a reload agree. + */ +for (const [width, height] of [ + [1440, 900], + [1024, 768], + [1024, 640], +] as const) { + test(`lays the episodes beside the shows at ${width}×${height}`, async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "a desktop window"); + await page.setViewportSize({ width, height }); + twoShows(); + await page.goto("/podcasts"); + const panel = page.locator("#podcast-panel"); + await expect(panel.getByRole("heading", { name: "Night Shift" })).toBeVisible(); + await expect(panel).toContainText("Second night"); + expect(await besideTheList(page)).toMatchObject({ right: true, overlap: true }); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBe(0); + + const day = page + .getByRole("list", { name: "Shows" }) + .getByRole("listitem") + .filter({ hasText: "Day Shift" }) + .getByRole("button", { name: "Episodes" }); + await day.click(); + await expect(page).toHaveURL(/[?&]channel=channel-2/); + await expect(panel.getByRole("heading", { name: "Day Shift" })).toBeVisible(); + await expect(panel).toContainText("First day"); + await expect(panel).not.toContainText("First night"); + await expect(day).toBeFocused(); + await expect(day).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("status").filter({ hasText: "Showing" })).toHaveText( + "Showing the episodes of Day Shift.", + ); + expect(await besideTheList(page)).toMatchObject({ right: true, overlap: true }); + await expectNoWcagViolations(page); + + // Back returns to the first show; a reload keeps the one named. + await page.goBack(); + await expect(panel.getByRole("heading", { name: "Night Shift" })).toBeVisible(); + await page.goto("/podcasts?channel=channel-2"); + await expect(panel.getByRole("heading", { name: "Day Shift" })).toBeVisible(); + }); +} + +/** A show named in the address that no longer exists opens the first. */ +test("opens the first show when the one in the address is gone", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "a desktop window"); + twoShows(); + await page.goto("/podcasts?channel=channel-gone"); + await expect( + page.locator("#podcast-panel").getByRole("heading", { name: "Night Shift" }), + ).toBeVisible(); +}); + +/** A long feed scrolls inside its panel, and the shows stay in view. */ +test("scrolls a long feed inside its panel, leaving the shows in reach", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "a desktop window"); + await page.setViewportSize({ width: 1024, height: 640 }); + twoShows(); + channels[0].episodes = Array.from({ length: 40 }, (_, index) => + episode("channel-1", `episode-long-${index}`, `Night ${index + 1}`), + ); + channels[0].episode_count = 40; + await page.goto("/podcasts"); + const panel = page.locator("#podcast-panel"); + await expect(panel).toContainText("Night 40"); + const sizes = await panel.evaluate((node) => ({ + scroll: node.scrollHeight, + client: node.clientHeight, + page: document.documentElement.scrollHeight, + })); + // The feed scrolls in its panel, which is no taller than the window: the + // page itself stays shorter than the feed it holds. + expect(sizes.scroll).toBeGreaterThan(sizes.client); + expect(sizes.client).toBeLessThanOrEqual(640); + expect(sizes.page).toBeLessThan(sizes.scroll); + await panel.evaluate((node) => { + node.scrollTop = node.scrollHeight; + }); + // The feed read to its end, the other show is still a click away. + const day = page + .getByRole("list", { name: "Shows" }) + .getByRole("listitem") + .filter({ hasText: "Day Shift" }) + .getByRole("button", { name: "Episodes" }); + await day.click(); + await expect(panel.getByRole("heading", { name: "Day Shift" })).toBeVisible(); +}); + +/** On a phone the episodes stay under the list, opened and closed on request. */ +test("stacks the episodes under the shows on a phone", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "a phone"); + twoShows(); + await page.goto("/podcasts"); + await expect(page.locator("#podcast-panel")).toHaveCount(0); + const night = page + .getByRole("list", { name: "Shows" }) + .getByRole("listitem") + .filter({ hasText: "Night Shift" }); + await night.getByRole("button", { name: "Episodes" }).click(); + await expect(page.locator("#podcast-panel")).toContainText("First night"); + expect(await besideTheList(page)).toMatchObject({ below: true }); + await night.getByRole("button", { name: "Hide" }).click(); + await expect(page.locator("#podcast-panel")).toHaveCount(0); + await expectNoWcagViolations(page); +}); + +/** + * Tab leaves the card's menu the way it leaves a menu button: from the "⋯", + * onwards to what follows it, or back with Shift. Left to run from inside the + * menu, which sits at the end of the page, it dropped focus on the body. + */ +test("leaves a card's menu by Tab from its button, not from the page's end", async ({ + page, +}) => { + stations = [ + stationFrom("station-1", { name: "Radio Nova", stream_url: "https://nova.example.org/live" }), + stationFrom("station-2", { name: "FIP", stream_url: "https://fip.example.org/live" }), + ]; + await page.goto("/radio"); + const more = page.getByRole("button", { name: "Actions for Radio Nova" }); + const menu = page.getByRole("menu", { name: "Actions for Radio Nova" }); + + await more.focus(); + await page.keyboard.press("Enter"); + await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(menu).toHaveCount(0); + await expect(page.getByRole("button", { name: "Listen to FIP" })).toBeFocused(); + + await more.focus(); + await page.keyboard.press("Enter"); + await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(menu).toHaveCount(0); + await expect(page.getByRole("button", { name: "Listen to Radio Nova" })).toBeFocused(); +}); diff --git a/webapp/src/components/action-menu.tsx b/webapp/src/components/action-menu.tsx new file mode 100644 index 00000000..5a4ccc5e --- /dev/null +++ b/webapp/src/components/action-menu.tsx @@ -0,0 +1,231 @@ +import { + type KeyboardEvent, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { Icon } from "./icons"; + +export type ActionMenuItem = { + key: string; + label: string; + run: () => void; + /** Drawn in the danger colour: the entry that destroys something. */ + danger?: boolean; +}; + +/** + * A "⋯" button and the short menu it opens: the actions on one card that do + * not deserve a button of their own. + * + * Drawn as the track menu is — the same panel and entries, the same keys of + * the ARIA menu pattern — but anchored to its button rather than to a pointer. + * Portalled to the body and fixed to the viewport for the track menu's reason: + * a page block rising into place carries a `translate`, and a fixed child of + * it would be placed inside the block instead of beside the button. + * + * Closing hands focus back to the button, before the chosen entry runs. + */ +export function ActionMenu({ + label, + items, + disabled = false, +}: { + /** The button's accessible name, which says whose actions these are. */ + label: string; + items: ActionMenuItem[]; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const button = useRef(null); + const menuId = useId(); + + function close(refocus: boolean) { + setOpen(false); + if (refocus) button.current?.focus(); + } + + return ( + <> + + {open && button.current + ? createPortal( + , + document.body, + ) + : null} + + ); +} + +function ActionMenuPanel({ + id, + label, + anchor, + items, + onClose, +}: { + id: string; + label: string; + anchor: HTMLElement; + items: ActionMenuItem[]; + onClose: (refocus: boolean) => void; +}) { + const list = useRef(null); + const [place, setPlace] = useState<{ left: number; top: number } | null>( + null, + ); + + // Under the button, its right edge on the button's, and moved back inside + // the window when that would hang off an edge — above the button when there + // is no room below it. + useLayoutEffect(() => { + const node = list.current; + if (!node) return; + const from = anchor.getBoundingClientRect(); + const rect = node.getBoundingClientRect(); + const margin = 8; + const below = from.bottom + 4; + const top = + below + rect.height + margin > window.innerHeight + ? from.top - rect.height - 4 + : below; + setPlace({ + left: Math.max( + margin, + Math.min( + from.right - rect.width, + window.innerWidth - rect.width - margin, + ), + ), + top: Math.max(margin, top), + }); + }, [anchor]); + + // Focus moves in once the menu is placed: hidden for its measurement, it + // could not take focus before. + const placed = place !== null; + useEffect(() => { + if (placed) { + list.current?.querySelector('[role="menuitem"]')?.focus(); + } + }, [placed]); + + // Anything outside closes it, as it closes the track menu; a press on the + // button is left to the button, whose click toggles it shut. + const closeRef = useRef(onClose); + closeRef.current = onClose; + useEffect(() => { + const away = (event: Event) => { + const target = event.target as Node; + if (list.current?.contains(target) || anchor.contains(target)) return; + closeRef.current(false); + }; + const leave = () => closeRef.current(false); + document.addEventListener("pointerdown", away, true); + document.addEventListener("wheel", leave, { capture: true, passive: true }); + window.addEventListener("blur", leave); + window.addEventListener("resize", leave); + return () => { + document.removeEventListener("pointerdown", away, true); + document.removeEventListener("wheel", leave, true); + window.removeEventListener("blur", leave); + window.removeEventListener("resize", leave); + }; + }, [anchor]); + + function onKeyDown(event: KeyboardEvent) { + const entries = Array.from( + list.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + const at = entries.indexOf(document.activeElement as HTMLElement); + const go = (index: number) => { + event.preventDefault(); + entries[(index + entries.length) % entries.length]?.focus(); + }; + switch (event.key) { + case "ArrowDown": + go(at + 1); + break; + case "ArrowUp": + go(at - 1); + break; + case "Home": + go(0); + break; + case "End": + go(entries.length - 1); + break; + case "Escape": + event.preventDefault(); + onClose(true); + break; + // Focus goes back to the button first and the Tab is left to run from + // there, so it lands on what follows the button (or, with Shift, what + // precedes it). Left in the menu, which sits at the end of the body, + // the Tab ran off the page and the menu's unmounting dropped focus on + // the body. + case "Tab": + onClose(true); + break; + } + } + + return ( + + ); +} diff --git a/webapp/src/components/page-shell.tsx b/webapp/src/components/page-shell.tsx index 6f67ade3..ed210474 100644 --- a/webapp/src/components/page-shell.tsx +++ b/webapp/src/components/page-shell.tsx @@ -22,6 +22,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useI18n } from "../state/i18n"; +import { Icon, type IconName } from "./icons"; /** Eight placeholder cards, keyed by name because their order is their only identity. */ const SKELETON_KEYS = [ @@ -139,10 +140,32 @@ export function PageHeader({ * second copy of four lines is how two screens start saying "nothing here" * with different spacing. */ -export function EmptyState({ message }: { message: string }) { +export function EmptyState({ + message, + icon, + detail, + children, +}: { + message: string; + /** A discreet symbol over the message, for a page that is new to someone. */ + icon?: IconName; + /** What the page is for, said under the message while there is nothing. */ + detail?: string; + /** The way out of the empty state, for those who may take it. */ + children?: ReactNode; +}) { return ( -
-

{message}

+
+ {icon ? ( + + + + ) : null} +

+ {message} +

+ {detail ?

{detail}

: null} + {children ?
{children}
: null}
); } diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 63f44e33..17eceff7 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -11,6 +11,7 @@ import { Outlet, RouterProvider, redirect, + useMatches, useNavigate, } from "@tanstack/react-router"; import { @@ -699,6 +700,12 @@ function ScopedOutlet() { /** Compose the authenticated desktop and mobile application chrome. */ function Shell() { const { t } = useI18n(); + // Read from the routes rather than set by each page, so the width holds + // while a page is still loading and the hub's sections share it. + const workspace = useMatches({ + select: (matches) => + matches.some((match) => match.staticData.layout === "workspace"), + }); return ( @@ -721,7 +728,11 @@ function Shell() { -
+
@@ -922,13 +933,22 @@ const queueRoute = createRoute({ const radioRoute = createRoute({ getParentRoute: () => authedRoute, path: "/radio", + staticData: { layout: "workspace" }, component: RadioPage, }); const podcastsRoute = createRoute({ getParentRoute: () => authedRoute, path: "/podcasts", - component: PodcastsPage, + staticData: { layout: "workspace" }, + // The channel whose episodes are open, so Back and a reload keep it. + validateSearch: (search: Record): { channel?: string } => ({ + channel: typeof search.channel === "string" ? search.channel : undefined, + }), + component: function PodcastsRoute() { + const { channel } = podcastsRoute.useSearch(); + return ; + }, }); /** @@ -1037,6 +1057,7 @@ const legacyAdminRoute = createRoute({ const foldersRoute = createRoute({ getParentRoute: () => authedRoute, path: "/folders", + staticData: { layout: "workspace" }, // The folder: absent or empty for the root, `unfiled` for the tracks filed // nowhere, otherwise a folder id. Optional, so the rail links to the root. validateSearch: (search: Record): { folder?: string } => ({ @@ -1056,6 +1077,9 @@ const foldersRoute = createRoute({ const maintenanceRoute = createRoute({ getParentRoute: () => authedRoute, path: "/maintenance", + // Every section under the hub inherits it: a child route's match carries + // its own static data, but the hub's match is in the list beside it. + staticData: { layout: "workspace" }, component: MaintenanceHub, }); @@ -1198,6 +1222,14 @@ declare module "@tanstack/react-router" { interface Register { router: typeof router; } + interface StaticDataRouteOption { + /** + * `workspace` for the pages that manage something rather than present + * it — Radio, Podcasts, Folders and the Maintenance hub. They take more of + * a wide window; the editorial pages keep the contained column. + */ + layout?: "workspace"; + } } const container = document.getElementById("root"); diff --git a/webapp/src/pages/folders-page.tsx b/webapp/src/pages/folders-page.tsx index 870bce91..d0d55880 100644 --- a/webapp/src/pages/folders-page.tsx +++ b/webapp/src/pages/folders-page.tsx @@ -362,7 +362,7 @@ export function FoldersPage({ folder }: { folder: string }) {
  • ) : ( <> - {folder !== UNFILED && (children.length || folder === "") ? ( + {folder !== UNFILED && children.length ? (
      {children.map((entry) => card(entry, entry.name, entry.track_count, entry.id), )} - {folder === "" - ? card( - null, - t("filed.unfiled"), - tree.data.unfiled_track_count, - UNFILED, - ) - : null}
    + ) : folder === "" ? ( +

    {t("folders.none")}

    + ) : null} + {/* To sort is not one of the folders counted in the header, so it + is not drawn among them: it sits apart, under a heading that + says who keeps it, with what it is and what cannot be done to + it. A badge alone, inside the same list, read as a folder + the header had forgotten to count. */} + {folder === "" ? ( +
    +

    {t("folders.system")}

    +

    + {t("folders.systemNote")} +

    +
      + {card( + null, + t("filed.unfiled"), + tree.data.unfiled_track_count, + UNFILED, + )} +
    +
    ) : null} {listsTracks ? ( !tracks.data ? ( diff --git a/webapp/src/pages/podcasts-page.tsx b/webapp/src/pages/podcasts-page.tsx index 3d7d7254..866aaaac 100644 --- a/webapp/src/pages/podcasts-page.tsx +++ b/webapp/src/pages/podcasts-page.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { type FormEvent, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { type FormEvent, useEffect, useRef, useState } from "react"; import { ApiError, @@ -28,6 +29,33 @@ import { type TranslationKey, useI18n } from "../state/i18n"; const statusKey = (status: PodcastEpisode["status"]) => `podcasts.status.${status}` as TranslationKey; +/** + * From this window width the channels and the open channel's episodes sit + * side by side; below it they stack. The same breakpoint as the + * `.podcast-workspace` rule in `styles.css`. + */ +const TWO_PANELS = "(min-width: 64rem)"; + +function twoPanelsQuery(): MediaQueryList | null { + return typeof window.matchMedia === "function" + ? window.matchMedia(TWO_PANELS) + : null; +} + +/** Whether the window is wide enough for the two panels. */ +function useTwoPanels(): boolean { + const [wide, setWide] = useState(() => twoPanelsQuery()?.matches ?? false); + useEffect(() => { + const query = twoPanelsQuery(); + if (!query) return; + const onChange = () => setWide(query.matches); + onChange(); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + return wide; +} + /** * Podcasts (RFC-025): the channels this server is subscribed to and their * episodes. @@ -37,15 +65,24 @@ const statusKey = (status: PodcastEpisode["status"]) => * the queue, as it does a radio station, and pauses the music under it. Its * position is the bookmark the server keeps for it, which the player writes * while it plays and this page reads back to say where it resumes. + * + * The open channel is `?channel=` in the address, so Back returns to the + * channel before and a reload keeps the one being read. On a wide window the + * episodes sit beside the channels, and the panel is never empty: with no + * channel named, or one that is gone, it shows the first. On a narrow one the + * episodes open under the list, and only when asked for, as they always have. */ -export function PodcastsPage() { +export function PodcastsPage({ channel }: { channel?: string }) { const { t } = useI18n(); + const navigate = useNavigate(); + const wide = useTwoPanels(); const { data: overview, error } = useQuery(podcastsQuery()); const reload = useReload(podcastsQuery().queryKey); const [url, setUrl] = useState(""); - const [selected, setSelected] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); + /** The feed field, which the empty state's action hands focus to. */ + const feedField = useRef(null); async function subscribe(event: FormEvent) { event.preventDefault(); @@ -53,9 +90,9 @@ export function PodcastsPage() { setBusy(true); setMessage(null); try { - const channel = await subscribePodcast(url.trim()); + const created = await subscribePodcast(url.trim()); setUrl(""); - setSelected(channel.id); + choose(created.id); reload(); } catch (failure) { setMessage( @@ -80,13 +117,24 @@ export function PodcastsPage() { } } - async function unsubscribe(channel: PodcastChannel) { + /** Opens a channel, or closes the open one, through the address. */ + function choose(id: string | null, replace = false) { + void navigate({ + to: "/podcasts", + search: id ? { channel: id } : {}, + replace, + }); + } + + async function unsubscribe(gone: PodcastChannel) { if (busy) return; setBusy(true); setMessage(null); try { - await unsubscribePodcast(channel.id); - if (selected === channel.id) setSelected(null); + await unsubscribePodcast(gone.id); + // Replaced rather than pushed: Back should not lead to a channel that + // no longer exists. + if (channel === gone.id) choose(null, true); reload(); } catch { setMessage(t("podcasts.mutationError")); @@ -97,6 +145,10 @@ export function PodcastsPage() { if (!overview) return ; + const named = overview.channels.find((entry) => entry.id === channel); + const open = named ?? (wide ? overview.channels[0] : undefined); + const selected = open?.id ?? null; + return (
    void subscribe(event)} > setUrl(event.target.value)} @@ -144,64 +197,102 @@ export function PodcastsPage() {

    ) : null} {overview.channels.length ? ( -
      - {overview.channels.map((channel) => ( -
    • -
      - {/* Fetched by the server into the artwork store: the feed's +
      +
        + {overview.channels.map((channel) => ( +
      • +
        + {/* Fetched by the server into the artwork store: the feed's own host is never asked by the browser. */} - } - /> -
        - {channel.title} - - {t("podcasts.episodes", { count: channel.episode_count })} - {channel.status === "error" - ? ` · ${t("podcasts.failed", { reason: channel.error ?? "" })}` - : ""} - + } + /> +
        + {channel.title} + + {t("podcasts.episodes", { count: channel.episode_count })} + {channel.status === "error" + ? ` · ${t("podcasts.failed", { reason: channel.error ?? "" })}` + : ""} + +
        -
        - - {overview.can_manage ? ( + {/* Beside the list, a channel is chosen rather than toggled: + the panel always shows one, so there is nothing to hide. + Focus stays on this button; the status below says what the + panel now holds. */} - ) : null} -
      • - ))} -
      + {overview.can_manage ? ( + + ) : null} +
    • + ))} +
    + {open ? ( + + ) : null} +
  • ) : ( - + // Said to everyone, since a reader meets this page empty too; only a + // manager is offered the way out, which is the field above. + + {overview.can_manage ? ( + + ) : null} + )} - {selected ? ( - - ) : null} + {/* Said once the panel changes: a channel chosen from the list keeps + focus on its button, so the new episodes are announced instead. */} +

    + {open ? t("podcasts.showing", { title: open.title }) : ""} +

    ); } @@ -237,6 +328,7 @@ function ChannelEpisodes({ return (
    diff --git a/webapp/src/pages/radio-page.tsx b/webapp/src/pages/radio-page.tsx index 7fc01baf..4ef373c6 100644 --- a/webapp/src/pages/radio-page.tsx +++ b/webapp/src/pages/radio-page.tsx @@ -1,5 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { type FormEvent, useState } from "react"; +import { + type FormEvent, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; import { ApiError, @@ -9,6 +16,7 @@ import { replaceRadioStation, } from "../api"; import { radioStationsQuery } from "../api/queries"; +import { ActionMenu } from "../components/action-menu"; import { Artwork } from "../components/artwork"; import { Icon } from "../components/icons"; import { @@ -69,15 +77,25 @@ const emptyDraft: Draft = { image_url: "", }; +/** + * The account's stations, as a grid of cards: each one's image, its name and + * the button that plays it. Adding and editing happen in a dialog that holds + * the form; editing and deleting are reached through the card's "⋯" menu, + * where a delete is asked to be confirmed first. + */ export function RadioPage() { const { t } = useI18n(); const player = usePlayer(); const { data: stations, error } = useQuery(radioStationsQuery()); const reload = useReload(radioStationsQuery().queryKey); const [draft, setDraft] = useState(emptyDraft); + /** Whether the dialog holding the form is open. */ + const [formOpen, setFormOpen] = useState(false); /** The station the form is replacing, or `null` when it is adding one. */ const [editing, setEditing] = useState(null); const [mutationError, setMutationError] = useState(null); + /** What had focus when the dialog opened, given focus back when it closes. */ + const returnTo = useRef(null); // The station is the player's, not this page's: it plays in the bar, and // carries on when the reader goes elsewhere. What this page shows of it is // read from there. @@ -93,14 +111,53 @@ export function RadioPage() { * reader had just filled, because `editing` and `draft` are read again * after the `await`. Closing the form removes both, where comparing the * values afterwards would have removed only the second (constats de revue, - * pris). Listening stays available throughout: it writes nothing, and a - * reader waiting on a save has no reason to lose the station they are - * hearing. + * pris). The dialog cannot be dismissed while it saves, for the same reason. + * Listening stays available throughout: it writes nothing, and a reader + * waiting on a delete has no reason to lose the station they are hearing. */ const [busy, setBusy] = useState(false); + // Focus goes back once the dialog has left the page: while it is still + // open and modal, everything behind it is inert and refuses focus. + const wasOpen = useRef(false); + useEffect(() => { + if (formOpen) { + wasOpen.current = true; + } else if (wasOpen.current) { + wasOpen.current = false; + returnTo.current?.focus(); + } + }, [formOpen]); + if (!stations) return ; + function openForm(station: RadioStation | null) { + returnTo.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + setMutationError(null); + setEditing(station); + setDraft( + station + ? { + name: station.name, + stream_url: station.stream_url, + homepage_url: station.homepage_url ?? "", + image_url: station.image_url ?? "", + } + : emptyDraft, + ); + setFormOpen(true); + } + + function closeForm() { + setFormOpen(false); + setEditing(null); + setDraft(emptyDraft); + setMutationError(null); + } + async function submit(event: FormEvent) { event.preventDefault(); if (busy) return; @@ -125,8 +182,7 @@ export function RadioPage() { } // Safe to clear because the form was closed for the whole round trip: // nothing the reader did in the meantime is being thrown away. - setDraft(emptyDraft); - setEditing(null); + closeForm(); reload(); } catch (failure) { const full = failure instanceof ApiError && failure.status === 409; @@ -138,6 +194,8 @@ export function RadioPage() { async function remove(station: RadioStation) { if (busy) return; + if (!window.confirm(t("radio.confirmDelete", { name: station.name }))) + return; setBusy(true); setMutationError(null); try { @@ -147,10 +205,6 @@ export function RadioPage() { // this request left, and stopping on that stale value would either leave // a deleted station sounding or stop one the reader had just started. player.stopAside(station.id); - if (editing?.id === station.id) { - setEditing(null); - setDraft(emptyDraft); - } reload(); } catch { setMutationError(t("radio.deleteError")); @@ -175,175 +229,283 @@ export function RadioPage() { !imageRefused; return ( -
    +
    -
    void submit(event)} > - - - - -

    - {t("radio.imageHint")} + + + {mutationError && !formOpen ? ( +

    + {mutationError}

    - {addressRefused || homepageRefused || imageRefused ? ( -

    {t("radio.addressRefused")}

    - ) : null} - {isBlockedAsMixedContent(draft.stream_url, window.location.protocol) ? ( -

    {t("radio.mixedContent")}

    - ) : null} -
    - - {editing ? ( - - ) : null} -
    - {editing ?

    {t("radio.replacing")}

    : null} -
    - {mutationError ?

    {mutationError}

    : null} + ) : null} {live && player.asideError ? (

    {t("radio.playbackError")}

    ) : null} {stations.length ? ( -
      - {stations.map((station) => ( -
    • -
      - } - /> -
      - {station.name} - +
        + {stations.map((station) => { + const playing = playingId === station.id; + return ( +
      • +
        + } + /> + +
        +
        +
        + {station.name} + openForm(station), + }, + { + key: "delete", + label: t("common.delete"), + danger: true, + run: () => void remove(station), + }, + ]} + /> +
        + {station.stream_url} {isBlockedAsMixedContent( station.stream_url, window.location.protocol, ) ? ( - {t("radio.mixedContent")} + + {t("radio.mixedContent")} + ) : null} {station.image_url && !station.artwork_hash ? ( - {t("radio.imageMissing")} + + {t("radio.imageMissing")} + + ) : null} + {station.homepage_url ? ( + + {t("radio.visit")} + ) : null}
        -
      - {station.homepage_url ? ( - - {t("radio.visit")} - - ) : null} - - - -
    • - ))} + + ); + })}
    ) : ( - + )} + {formOpen ? ( + + {(close) => ( +
    void submit(event)} + > + + + + +

    + {t("radio.imageHint")} +

    + {addressRefused || homepageRefused || imageRefused ? ( +

    {t("radio.addressRefused")}

    + ) : null} + {isBlockedAsMixedContent( + draft.stream_url, + window.location.protocol, + ) ? ( +

    {t("radio.mixedContent")}

    + ) : null} + {editing ?

    {t("radio.replacing")}

    : null} + {mutationError ? ( +

    + {mutationError} +

    + ) : null} +
    + + +
    +
    + )} +
    + ) : null}
    ); } + +/** + * The dialog the station form sits in: a native `` opened with + * `showModal`, as the folder picker is, so the focus trap, Escape and the + * inert page behind come from the platform. + * + * `locked` holds it open while a save is in flight: Escape, the backdrop and + * the close button would otherwise put the form away under a request that is + * about to report into it. + */ +function StationDialog({ + title, + locked, + onClose, + children, +}: { + title: string; + locked: boolean; + onClose: () => void; + /** Handed the way to close, for the form's own Cancel button. */ + children: (close: () => void) => ReactNode; +}) { + const { t } = useI18n(); + const dialog = useRef(null); + const titleId = useId(); + + useEffect(() => { + const node = dialog.current; + if (node && !node.open) node.showModal(); + }, []); + + const close = () => { + if (!locked) dialog.current?.close(); + }; + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes a natively + { + if (locked) event.preventDefault(); + }} + onClick={(event) => { + // A click on the backdrop lands on the dialog itself, outside its box. + const node = dialog.current; + if (!node || event.target !== node) return; + const box = node.getBoundingClientRect(); + const inside = + event.clientX >= box.left && + event.clientX <= box.right && + event.clientY >= box.top && + event.clientY <= box.bottom; + if (!inside) close(); + }} + > +
    +

    {title}

    + +
    + {children(close)} +
    + ); +} diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index c2fcd7c2..94f73d2f 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -399,7 +399,7 @@ const en = { "radio.listen": "Listen", "radio.stop": "Stop", "radio.visit": "Station page", - "radio.empty": "No stations yet. Add one above.", + "radio.empty": "No stations yet. Add one to keep it here.", "radio.addressRefused": "An address must start with http:// or https://.", "radio.mixedContent": "This page is served over HTTPS, so your browser will refuse a clear-text stream.", @@ -408,6 +408,14 @@ const en = { "radio.deleteError": "The station could not be deleted.", "radio.playbackError": "That station could not be played. The server never connects to it, so this is between your browser and the station.", + "radio.stations": "Stations", + "radio.newTitle": "Add a station", + "radio.editTitle": "Edit “{name}”", + "radio.listenTo": "Listen to {name}", + "radio.stopNamed": "Stop {name}", + "radio.actions": "Actions for {name}", + "radio.confirmDelete": + "Delete the station “{name}”? Its address and image are forgotten.", "credential.password": "Dedicated Subsonic password", "credential.rotate": "Rotate credential", "credential.copy": "Copy this API key now:", @@ -646,10 +654,17 @@ const en = { "artistImage.deezerBusy": "Your previous search is still running. Try again in a moment.", "artistImage.deezerExpired": "These results have expired. Search again.", - "folders.count": { one: "{count} folder", other: "{count} folders" }, + "folders.count": { + one: "{count} folder created in this library", + other: "{count} folders created in this library", + }, "folders.where": "Folder path", "folders.subfolders": "Folders", "folders.noLibrary": "No library is available to this account.", + "folders.none": "No folder has been created in this library yet.", + "folders.system": "Kept by WaveFlow", + "folders.systemNote": + "To sort gathers every track that is in no folder. WaveFlow keeps it itself: it is not counted among the folders above, and it cannot be renamed, moved or deleted.", "filed.unfiled": "To sort", "filed.automatic": "Automatic", "filed.topLevel": "Top level", @@ -1117,6 +1132,11 @@ const en = { "podcasts.refreshing": "The feeds are being fetched again.", "podcasts.reload": "Reload", "podcasts.empty": "No podcasts yet.", + "podcasts.emptyDetail": + "An administrator subscribes to a show’s feed here. The server downloads its episodes, and every account on this server can listen to them.", + "podcasts.addFirst": "Add your first podcast", + "podcasts.channels": "Shows", + "podcasts.showing": "Showing the episodes of {title}.", "podcasts.noEpisodes": "This feed has no episodes.", "podcasts.episodes": { one: "{count} episode", @@ -1544,7 +1564,7 @@ const fr: Record = { "radio.listen": "Écouter", "radio.stop": "Arrêter", "radio.visit": "Page de la station", - "radio.empty": "Aucune station. Ajoutez-en une ci-dessus.", + "radio.empty": "Aucune station. Ajoutez-en une pour la retrouver ici.", "radio.addressRefused": "Une adresse doit commencer par http:// ou https://.", "radio.mixedContent": "Cette page est servie en HTTPS : votre navigateur refusera un flux en clair.", @@ -1553,6 +1573,14 @@ const fr: Record = { "radio.deleteError": "La station n’a pas pu être supprimée.", "radio.playbackError": "Cette station n’a pas pu être lue. Le serveur ne s’y connecte jamais : cela se passe entre votre navigateur et la station.", + "radio.stations": "Stations", + "radio.newTitle": "Ajouter une station", + "radio.editTitle": "Modifier « {name} »", + "radio.listenTo": "Écouter {name}", + "radio.stopNamed": "Arrêter {name}", + "radio.actions": "Actions pour {name}", + "radio.confirmDelete": + "Supprimer la station « {name} » ? Son adresse et son image sont oubliées.", "credential.password": "Mot de passe Subsonic dédié", "credential.rotate": "Renouveler l’identifiant", "credential.copy": "Copiez cette clé API maintenant :", @@ -1792,10 +1820,17 @@ const fr: Record = { "Votre recherche précédente est encore en cours. Réessayez dans un instant.", "artistImage.deezerExpired": "Ces résultats ont expiré. Relancez la recherche.", - "folders.count": { one: "{count} dossier", other: "{count} dossiers" }, + "folders.count": { + one: "{count} dossier créé dans cette bibliothèque", + other: "{count} dossiers créés dans cette bibliothèque", + }, "folders.where": "Chemin du dossier", "folders.subfolders": "Dossiers", "folders.noLibrary": "Aucune bibliothèque n’est accessible à ce compte.", + "folders.none": "Aucun dossier n’a encore été créé dans cette bibliothèque.", + "folders.system": "Tenu par WaveFlow", + "folders.systemNote": + "À ranger réunit toutes les pistes qui ne sont dans aucun dossier. WaveFlow le tient lui-même : il n’est pas compté parmi les dossiers ci-dessus, et il ne peut être ni renommé, ni déplacé, ni supprimé.", "filed.unfiled": "À ranger", "filed.automatic": "Automatique", "filed.topLevel": "Premier niveau", @@ -2275,6 +2310,11 @@ const fr: Record = { "podcasts.refreshing": "Les flux sont en cours de récupération.", "podcasts.reload": "Recharger", "podcasts.empty": "Aucun podcast pour l’instant.", + "podcasts.emptyDetail": + "Un administrateur abonne le serveur au flux d’une émission ici. Le serveur télécharge ses épisodes, et tous les comptes de ce serveur peuvent les écouter.", + "podcasts.addFirst": "Ajouter votre premier podcast", + "podcasts.channels": "Émissions", + "podcasts.showing": "Épisodes de {title} affichés.", "podcasts.noEpisodes": "Ce flux ne contient aucun épisode.", "podcasts.episodes": { one: "{count} épisode", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index d158ba6b..e7ae48f1 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -4131,3 +4131,318 @@ button.danger { .lyrics-credit { margin-top: var(--space-2); } + +/* ── Workspace pages ─────────────────────────────────────────── + Radio, Podcasts, Folders and the Maintenance hub manage something rather + than present it, and a wide window is room for more of their cards and + rows. Their routes mark `main` with `.workspace` (see `staticData.layout` + in `main.tsx`); every other page keeps the contained column. The class + outranks every sheet's plain `main` rule, and on a phone `min()` resolves + to the full width those rules already give, gutters untouched. */ +main.workspace { + width: min(100%, 150rem); +} + +/* The radio page: one card per station, its image, its name and the button + that plays it; the rest behind the card's "⋯" menu. */ +.station-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 12.5rem), 1fr)); + gap: var(--space-6); + margin: 0; + padding: 0; + list-style: none; +} + +/* Two to a row on a phone: one card the width of the screen is a poster, + and a short list of stations should be seen at a glance. */ +@media (max-width: 820px) { + .station-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + + .station-art .station-play { + right: var(--space-2); + bottom: var(--space-2); + width: 2.75rem; + min-height: 2.75rem; + } +} + +.station-card { + display: grid; + align-content: start; + gap: var(--space-3); + min-width: 0; +} + +.station-art { + position: relative; +} + +.station-cover { + width: 100%; +} + +/* On the image, where the eye already is, as a cover's play button is. */ +.station-art .station-play { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + display: grid; + width: 3rem; + min-height: 3rem; + padding: 0; + place-items: center; + border-color: transparent; + border-radius: 999px; + background: var(--accent); + color: var(--fe-accent-ink, var(--bg)); + box-shadow: var(--shadow); +} + +.station-art .station-play:hover { + background: var(--accent); + filter: brightness(1.08); +} + +.station-copy { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.station-title { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); +} + +.station-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.station-url { + overflow: hidden; + font-size: var(--text-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.station-note { + font-size: var(--text-xs); +} + +.station-link { + justify-self: start; + color: var(--accent); + font-size: var(--text-xs); +} + +/* A card's "⋯": quiet until it is pointed at, a full target all the same. */ +.action-menu-button { + display: grid; + width: 2.25rem; + min-height: 2.25rem; + padding: 0; + place-items: center; + border-color: transparent; + border-radius: 999px; + background: transparent; + color: var(--muted); +} + +.action-menu-button:hover, +.action-menu-button[aria-expanded="true"] { + color: var(--text); +} + +.track-menu-item.danger { + background: transparent; + color: var(--danger); +} + +.track-menu-item.danger:hover, +.track-menu-item.danger:focus-visible { + background: color-mix(in srgb, var(--danger) 12%, transparent); +} + +/* The station form, in a dialog drawn as the folder picker is. */ +.station-dialog[open] { + display: grid; + gap: var(--space-4); + width: min(34rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); + padding: var(--space-6); + overflow-y: auto; + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +/* A page's blocks rise into place as it opens (`fe-rise` in fluid.css), and + a dialog mounted later as one of them rose with them: invisible for the + delay, then fading in under the reader's click. A dialog opens at once. */ +main > section > dialog { + animation: none; +} + +.station-dialog::backdrop { + background: color-mix(in srgb, black 55%, transparent); +} + +.station-dialog header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); +} + +.station-dialog h2 { + margin: 0; + font-size: var(--text-lg); + overflow-wrap: anywhere; +} + +.station-dialog .radio-form { + margin: 0; +} + +/* The form's notes sit in its grid gap, without margins of their own. */ +.station-dialog .radio-form > :where(p) { + margin: 0; +} + +/* An empty page that is new to somebody: a discreet symbol, what the page + is for, and the first step when the reader may take it. */ +.empty-state-rich { + align-content: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-6); +} + +.empty-state-icon { + display: grid; + width: 3.5rem; + height: 3.5rem; + place-items: center; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); +} + +.empty-state-title { + margin: 0; + color: var(--text); + font-weight: 600; +} + +.empty-state-detail { + margin: 0; + max-width: 36rem; + font-size: var(--text-sm); +} + +.empty-state-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-3); + margin-top: var(--space-2); +} + +/* The folders page: To sort apart from the folders it does not count. */ +.folder-none { + margin: 0 0 var(--space-6); +} + +.folder-system { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-6); +} + +.folder-system h3 { + margin: 0; + font-size: var(--text-base); +} + +.folder-system-note { + max-width: 48rem; + margin: 0 0 var(--space-2); + font-size: var(--text-sm); +} + +.folder-system .folder-list { + margin-bottom: 0; +} + +/* Drawn in outline, not filled: a tray WaveFlow keeps, not a folder. */ +.folder-row.system { + border-style: dashed; + background: transparent; +} + +/* Read by a screen reader, not drawn: a status the eye gets from the page. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + border: 0; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The podcasts page in two panels from 1024 px of window: the shows on the + left, the open show's episodes beside them. The same breakpoint as + `TWO_PANELS` in `podcasts-page.tsx`, which also opens the first show when + none is named. Below it the episodes stack under the list, as before. */ +@media (width >= 64rem) { + .podcast-workspace { + display: grid; + /* A third of the room for the shows, never more than 22rem: at 1024 px + the episodes, which carry the buttons, keep the larger share. */ + grid-template-columns: minmax(14rem, min(22rem, 34%)) minmax(0, 1fr); + align-items: start; + gap: var(--space-6); + } + + /* A narrow column: the name over the buttons, rather than beside them. */ + .podcast-workspace .podcast-channels li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-3); + padding-inline: var(--space-3); + } + + .podcast-workspace .podcast-channels li > .resource-with-cover { + flex: 1 1 100%; + } + + .podcast-workspace .podcast-channels li.selected { + background: var(--fe-tone-2, var(--panel-strong)); + box-shadow: inset 2px 0 0 var(--accent); + } + + /* Held in view while the list scrolls, and scrolled on its own when it is + longer than the window, above the player bar: a long feed never pushes + the shows out of reach. */ + .podcast-workspace .podcast-episodes { + position: sticky; + top: var(--space-6); + max-height: calc(100dvh - 9rem); + margin-top: 0; + overflow-y: auto; + overscroll-behavior: contain; + } +}