diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index a2136228..b3b46db9 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { clickVisibleChannelLink, openApp } from "./helpers"; +import { clickVisibleChannelLink, expectContentType, openApp, toggleContentType } from "./helpers"; // Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and // both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch @@ -68,29 +68,22 @@ test.describe("channel view", () => { }) => { await openApp(page); await expect(page.getByTestId("video-card").first()).toBeVisible(); - const feedLive = page.getByTestId("feed-content-includeLive"); - await expect(feedLive).toHaveAttribute("aria-pressed", "false"); + await expectContentType(page, "includeLive", false); // Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on. await page.getByTestId("video-card-channel").first().click(); await expect(page.getByTestId("channel-title")).toBeVisible(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( - "aria-pressed", - "false", - ); + await expectContentType(page, "includeLive", false); await page.getByTestId("channel-back").click(); await expect(page.getByTestId("channel-title")).toHaveCount(0); // Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state. - await feedLive.click(); - await expect(feedLive).toHaveAttribute("aria-pressed", "true"); + await toggleContentType(page, "includeLive"); + await expectContentType(page, "includeLive", true); await expect(page.getByTestId("video-card").first()).toBeVisible(); await page.getByTestId("video-card-channel").first().click(); await expect(page.getByTestId("channel-title")).toBeVisible(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( - "aria-pressed", - "true", - ); + await expectContentType(page, "includeLive", true); }); // Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there @@ -99,27 +92,18 @@ test.describe("channel view", () => { test("a channel's content toggle does not leak into the feed", async ({ page }) => { await openApp(page); await expect(page.getByTestId("video-card").first()).toBeVisible(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( - "aria-pressed", - "false", - ); + await expectContentType(page, "includeLive", false); // Turn Live/Upcoming ON inside the channel view. await page.getByTestId("video-card-channel").first().click(); await expect(page.getByTestId("channel-title")).toBeVisible(); - await page.getByTestId("feed-content-includeLive").click(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( - "aria-pressed", - "true", - ); + await toggleContentType(page, "includeLive"); + await expectContentType(page, "includeLive", true); // Back on the feed, its Live/Upcoming is still off — the channel's change stayed local. await page.getByTestId("channel-back").click(); await expect(page.getByTestId("channel-title")).toHaveCount(0); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( - "aria-pressed", - "false", - ); + await expectContentType(page, "includeLive", false); }); // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays @@ -127,7 +111,7 @@ test.describe("channel view", () => { test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => { await openApp(page); await page.getByTestId("video-card-channel").first().click(); - const count = page.getByTestId("feed-result-count"); + const count = page.getByTestId("item-count"); const title = page.getByTestId("channel-title"); await expect(count).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible(); diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts index 937f667b..55700b6f 100644 --- a/frontend/e2e/feed.spec.ts +++ b/frontend/e2e/feed.spec.ts @@ -8,15 +8,18 @@ test.describe("feed", () => { await openApp(page); await expect(page.getByTestId("video-card").first()).toBeVisible(); - const count = page.getByTestId("feed-result-count"); + const count = page.getByTestId("item-count"); await expect(count).toHaveText(/\d/); const baseline = await count.innerText(); - // Clean baseline: default filters, so no chips and "unwatched" is the active show. + // Clean baseline: default filters, so no chips and "unwatched" is the active show (the Watch-state + // pill summarises the current pick). await expect(page.getByTestId("active-filter-chip")).toHaveCount(0); - await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("feed-show")).toContainText("Unwatched"); - // Narrow to "watched" — the seed guarantees exactly one, a real deterministic change. + // Narrow to "watched" — open the Watch-state dropdown and pick it (single-select closes on pick). + // The seed guarantees exactly one, a real deterministic change. + await page.getByTestId("feed-show").click(); await page.getByTestId("feed-show-watched").click(); await expect(page.getByTestId("active-filter-chip")).toHaveCount(1); await expect(count).not.toHaveText(baseline); @@ -24,7 +27,7 @@ test.describe("feed", () => { // Remove the chip → default show restored, chips gone. await page.getByTestId("active-filter-chip").click(); await expect(page.getByTestId("active-filter-chip")).toHaveCount(0); - await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("feed-show")).toContainText("Unwatched"); }); test("scrolling virtualizes in fresh cards", async ({ page }) => { @@ -52,7 +55,7 @@ test.describe("feed", () => { // pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll. test("the feed toolbar stays pinned while cards scroll", async ({ page }) => { await openApp(page); - const count = page.getByTestId("feed-result-count"); + const count = page.getByTestId("item-count"); await expect(count).toBeVisible(); const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top); @@ -73,6 +76,7 @@ test.describe("feed", () => { expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500); // Switch the show filter → a different result set → back to the top. + await page.getByTestId("feed-show").click(); await page.getByTestId("feed-show-all").click(); await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50); }); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index de3c4693..0b1b3c6c 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -33,6 +33,30 @@ export async function clickVisibleChannelLink(page: Page): Promise { return false; } +/** + * Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown. + * The options only exist while the dropdown is open, so this opens it, checks the option's + * `aria-checked` (the multi FilterSelect renders its rows as menuitemcheckbox), then closes it + * (Escape) so the floating panel can't block a later click. + */ +export async function expectContentType(page: Page, value: string, on: boolean): Promise { + await page.getByTestId("feed-content").click(); + await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute( + "aria-checked", + on ? "true" : "false", + ); + await page.keyboard.press("Escape"); + await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0); +} + +/** Toggle a content-type via the Content-types FilterSelect dropdown, then close it. */ +export async function toggleContentType(page: Page, value: string): Promise { + await page.getByTestId("feed-content").click(); + await page.getByTestId(`feed-content-${value}`).click(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0); +} + /** Open the in-app player from the first feed card (clicks its thumbnail link). */ export async function openFirstVideo(page: Page): Promise { await expect(page.getByTestId("video-card").first()).toBeVisible(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5af82e6e..9ec6a00d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -136,6 +136,19 @@ export default function App() { if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); }, [meQuery.data?.id]); + // Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the + // toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves + // that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough. + useEffect(() => { + const probe = document.createElement("div"); + probe.style.cssText = + "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable"; + document.body.appendChild(probe); + const w = probe.offsetWidth - probe.clientWidth; + document.body.removeChild(probe); + document.documentElement.style.setProperty("--sbw", `${w}px`); + }, []); + // Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify, // ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the // URL. Gated on auth so the logged-out Welcome page still reads its params first. @@ -300,7 +313,7 @@ export default function App() { const me = meQuery.data!; const pageGate = moduleDef(page).gate; const activePage: Page = !pageGate || pageGate(me) ? page : "feed"; - const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage]; + const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage]; const railDef = channelView ? undefined : PAGE_RAIL[activePage]; return ( @@ -366,6 +379,7 @@ export default function App() { ) : ( - + {t("feed.chips.label")} {filters.map((f) => ( @@ -32,7 +32,7 @@ export default function ActiveFilterChips({ onClick={f.remove} title={t("feed.chips.remove", { name: f.label })} aria-label={t("feed.chips.remove", { name: f.label })} - className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition" + className="chip group pl-2.5 pr-1.5 py-1 text-sm hover:border-accent hover:bg-accent/20 transition" > {f.label} @@ -42,7 +42,7 @@ export default function ActiveFilterChips({ diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 76e74b0e..445dea8a 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -34,7 +34,7 @@ export default function AdminUsers() { <> {/* The tab bar is fixed chrome; the tab content scrolls under it. */} -
+
diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 4334425c..caa64822 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -200,7 +200,7 @@ export default function ChannelDiscovery({ <> {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */} -
+

{t("channels.discovery.intro")}

{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */} {layout === "cards" && ( diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index e09f037b..3c4386de 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -1,11 +1,8 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { X } from "lucide-react"; import { api, type FeedFilters } from "../lib/api"; -import { useScrollFade } from "../lib/useScrollFade"; -import { inputCls } from "./ui/form"; +import Combobox, { type ComboboxOption } from "./Combobox"; // Pick channels to narrow the feed to — several OR together, so every other filter then applies // across all of them. Until this existed the filter was settable only via a shared "?channel=" link: @@ -22,86 +19,29 @@ export default function ChannelPicker({ setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); - const [query, setQuery] = useState(""); - // Same edge-fade affordance the nav rail and the panel body use to hint at more content. - const fade = useScrollFade(); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels, staleTime: 5 * 60_000, }); - const q = query.trim().toLowerCase(); const all = channelsQuery.data ?? []; - const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all; - const picked = filters.channelIds; - const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id; - const toggle = (id: string) => - setFilters({ - ...filters, - channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id], - }); + // The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the + // options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later + // narrowing ever drops it from the visible rows. + const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id })); + const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id])); return ( - <> - {/* The picks sit above the list so they stay visible once the list scrolls away. */} - {picked.length > 0 && ( -
- {picked.map((id) => ( - - ))} -
- )} - setQuery(e.target.value)} - placeholder={t("sidebar.channelSearch")} - aria-label={t("sidebar.channelSearch")} - className={inputCls} - /> - {/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks - random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing - but does drop the rows that explain it. */} - {!q && all.length > 0 && ( -
{t("sidebar.channelOrder")}
- )} -
- {matches.length === 0 ? ( -
- {channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")} -
- ) : ( - matches.map((c) => { - const on = picked.includes(c.id); - return ( - - ); - }) - )} -
- + setFilters({ ...filters, channelIds })} + loading={channelsQuery.isLoading} + emptyText={t("sidebar.noChannelMatch")} + selectedLabels={selectedLabels} + /> ); } diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index 8cfab27b..fe459da5 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -154,7 +154,7 @@ export default function Combobox({ className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30" > {selected.map((v) => ( - + {labelFor(v)} - ); - })} -
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept — it selects how videos ENTERED the library. On a channel-scoped view we already show all of this one channel's videos (librarySource is pinned to "all"), so the selector would only offer a near-empty, confusing slice — hide it here. */} {!channelScoped && ( - <> -