diff --git a/CLAUDE.md b/CLAUDE.md index af4f1665..d580fc7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,8 +63,9 @@ Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` rout components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That boundary is one-way and **lint-enforced**, not just described: a platform component may not import a -feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own -test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md). +feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at +all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic +component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md). Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*` slices over `core.req`. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 7a85a064..49b74a83 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -109,5 +109,27 @@ export default tseslint.config( ], }, }, + { + // E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks, + // the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a + // type. That is how the `Column` contract came to be owned by a ui component that three lib + // helpers had to import from. A piece both layers need moves down into `lib/`. + files: ["src/lib/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + // Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too. + regex: String.raw`^(\.\./)+components(/|$)`, + message: + "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.", + }, + ], + }, + ], + }, + }, prettier, ); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index f35a436d..b965ad0c 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -31,7 +31,7 @@ import ChannelStatsPopover from "./ChannelStatsPopover"; import Pager from "./ui/Pager"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; -import { filterRows } from "./ui/tableHeader"; +import { filterRows } from "../lib/tableColumns"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./ui/PageShell"; import ChannelDiscovery from "./ChannelDiscovery"; diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 4111b803..efa31cb2 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -23,7 +23,7 @@ import { type ChannelsUrlState, } from "../lib/channelsUrlState"; import { usePersistedTab } from "./ui/Tabs"; -import type { FilterMap } from "./ui/tableHeader"; +import type { FilterMap } from "../lib/tableColumns"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import type { ChannelsView } from "./Channels"; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 2159391a..5de0b221 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -5,13 +5,8 @@ import { useNavigationActions } from "./NavigationProvider"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { usePref } from "../lib/usePref"; -import { - AUTO_MODES, - LOOP_MODES, - PlayerTransportControls, - type AutoMode, - type LoopMode, -} from "./playerModalControls"; +import { PlayerTransportControls } from "./playerModalControls"; +import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes"; import { AlertTriangle, ArrowLeft, diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 252cd2cc..5962d177 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -62,14 +62,8 @@ import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylists } from "./PlaylistsProvider"; import { useMe } from "../lib/useMe"; import { usePref } from "../lib/usePref"; -import { - ColumnHead, - alignClass, - filterRows, - isActive, - type Column, - type FilterMap, -} from "./ui/tableHeader"; +import { ColumnHead, alignClass } from "./ui/tableHeader"; +import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns"; import { GRIP_KEY, playlistColumns } from "./playlistColumns"; // The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx index ddc89092..75704a85 100644 --- a/frontend/src/components/playerModalControls.tsx +++ b/frontend/src/components/playerModalControls.tsx @@ -1,16 +1,10 @@ import { useTranslation } from "react-i18next"; import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; +import type { AutoMode, LoopMode } from "../lib/playerModes"; // Presentational controls for the YouTube modal player (PlayerModal), split out of the god component — // the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props. - -// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video -// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or -// neither ("off"). Both apply to any queued player (feed or playlist). -export type AutoMode = "off" | "next" | "prev" | "random"; -export type LoopMode = "off" | "one" | "all"; -export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; -export const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; +// The auto-advance / loop modes it renders are defined in `lib/playerModes`. /** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index 5d01b2e3..03269c38 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -2,7 +2,7 @@ import { Play, X } from "lucide-react"; import type { TFunction } from "i18next"; import type { Video } from "../lib/api"; import { formatDuration } from "../lib/format"; -import type { Column } from "./ui/tableHeader"; +import type { Column } from "../lib/tableColumns"; // Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`. // diff --git a/frontend/src/components/ui/CommandPalette.dom.test.tsx b/frontend/src/components/ui/CommandPalette.dom.test.tsx index 70bd113b..3a3abaca 100644 --- a/frontend/src/components/ui/CommandPalette.dom.test.tsx +++ b/frontend/src/components/ui/CommandPalette.dom.test.tsx @@ -5,7 +5,7 @@ // (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough. import { useState } from "react"; import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; import CommandPalette, { type CommandGroup } from "./CommandPalette"; vi.mock("react-i18next", async (importOriginal) => { @@ -160,3 +160,105 @@ describe("CommandPalette", () => { expect(ran).toEqual(["closed"]); }); }); + +// The owner's case (E11 S3): typing "theme" put a channel matched only through its handle above every +// "Theme: …" row, and Enter opened the channel. +describe("CommandPalette · ranking", () => { + const noop = () => {}; + const ranked = (): CommandGroup[] => [ + { + id: "channels", + label: "Channels", + items: [ + { id: "ch:melt", label: "The Meltzone Podcast", hint: "@themeltzone", run: noop }, + { id: "ch:talk", label: "Talk about themes", run: noop }, + ], + }, + { + id: "actions", + label: "Actions", + items: [ + { id: "theme:midnight", label: "Theme: Midnight", run: noop }, + { id: "theme:forest", label: "Theme: Forest", run: noop }, + ], + }, + ]; + + it("ranks rows inside each group, keeps the group order, and starts on the best match", () => { + render(); + type("theme"); + // label elsewhere (talk) > handle only (melt); label start (midnight = forest, group order kept) + expect(rows()).toEqual(["ch:talk", "ch:melt", "theme:midnight", "theme:forest"]); + // The highlight is what Enter takes (same `activeIndex`); the full close→run cycle cannot run in + // this file — see the note on "chooses the selected row on Enter". + expect(selected()).toBe("theme:midnight"); + }); + + it("ranks by the worst word first, so every word in the label beats a label start plus a keyword", () => { + const groups: CommandGroup[] = [ + { + id: "channels", + label: "Channels", + items: [ + { id: "weekly", label: "Podcast Weekly", keywords: "melt", run: noop }, + { id: "melt", label: "The Meltzone Podcast", run: noop }, + ], + }, + ]; + render(); + type("podcast melt"); + expect(rows()).toEqual(["melt", "weekly"]); + }); + + // The channel and playlist lists load after the palette opens — often while you are already typing. + it("follows the best match as a list lands, but keeps a row the arrows picked", () => { + const early: CommandGroup[] = [ranked()[0]!]; + const r = render(); + type("theme"); + expect(selected()).toBe("ch:talk"); + r.rerender(); + expect(selected()).toBe("theme:midnight"); + + press("ArrowDown"); // theme:forest, picked + r.rerender(); + expect(selected()).toBe("theme:forest"); + type("them"); // a new query goes back to following the best match + expect(selected()).toBe("theme:midnight"); + }); +}); + +describe("CommandPalette · accessibility", () => { + it("names each group by its heading", () => { + open(); + const channels = screen.getByRole("group", { name: "Channels" }); + expect( + [...channels.querySelectorAll("[role='option']")].map((o) => o.getAttribute("data-cmd-id")), + ).toEqual(["ch:1", "ch:2"]); + expect(screen.getByRole("group", { name: "Modules" })).toBeTruthy(); + }); + + it("is expanded only while there is something to choose, and says 'nothing' outside the listbox", () => { + open(); + expect(field().getAttribute("aria-expanded")).toBe("true"); + type("zzzz"); + expect(field().getAttribute("aria-expanded")).toBe("false"); + const listbox = screen.getByRole("listbox", { hidden: true }); + expect(listbox.hidden).toBe(true); + // aria-controls still names a real element + expect(document.getElementById(field().getAttribute("aria-controls")!)).toBe(listbox); + expect(listbox.contains(screen.getByText("palette.empty"))).toBe(false); + }); + + it("announces the result count politely, once the typing pauses", async () => { + open(); + const status = screen.getByRole("status"); + expect(status.textContent).toBe(""); // nothing to announce on a blank field + type("the"); + expect(status.textContent).toBe(""); // not per keystroke + await waitFor(() => expect(status.textContent).toBe("palette.results")); + type("zzzz"); + await waitFor(() => expect(status.textContent).toBe("palette.empty")); + type(""); + expect(status.textContent).toBe(""); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index aff1833a..0102f038 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -40,14 +40,37 @@ function fold(s: string): string { .replace(/\p{Diacritic}/gu, ""); } -/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so - * "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */ -function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean { - if (words.length === 0) return true; - const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`); - return words.every((w) => hay.includes(w)); +/** How well an item answers the query: `[worst word, sum of words]`, lower is better. */ +type Rank = readonly [number, number]; + +const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1]; + +/** Every query word must appear somewhere in the item's text — word-wise rather than substring, so + * "thevr pod" finds "TheVR Podcast", the way you actually half-remember a name. `null` = no match. + * + * Each word scores 0 when the label STARTS with it, 1 when it is elsewhere in the label, 2 when only + * the hint, the hidden keywords or the group name carry it. An item ranks by its worst word first, so + * a row whose label holds every word beats one that needed its handle for any of them. Before this + * the list was pure group order: "theme" put the channel "The Meltzone Podcast" — matched through its + * handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel (E11 S3). */ +function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank | null { + const label = fold(item.label); + const rest = fold(`${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`); + let worst = 0; + let sum = 0; + for (const w of words) { + const score = label.startsWith(w) ? 0 : label.includes(w) ? 1 : rest.includes(w) ? 2 : -1; + if (score < 0) return null; + worst = Math.max(worst, score); + sum += score; + } + return [worst, sum]; } +/** The result count reaches a screen reader only once the typing pauses: a polite region rewritten on + * every keystroke queues one announcement per letter. */ +const ANNOUNCE_DELAY_MS = 400; + export default function CommandPalette({ open, onClose, @@ -59,8 +82,12 @@ export default function CommandPalette({ }) { const { t } = useTranslation(); const listId = useId(); + const groupId = useId(); const [query, setQuery] = useState(""); - const [active, setActive] = useState(0); + // The row the arrows or the pointer picked, by id; `null` follows the best match. An id rather than + // an index because the lists arrive asynchronously (channels, playlists load on open), and an index + // would quietly point at a different row once they land. + const [picked, setPicked] = useState(null); // Ref on the highlighted row rather than a selector lookup: item ids come from domain data // (playlist names, channel handles) and would need escaping to be safe in a query string. const activeRef = useRef(null); @@ -70,26 +97,58 @@ export default function CommandPalette({ useEffect(() => { if (open) { setQuery(""); - setActive(0); + setPicked(null); } }, [open]); - const visible = useMemo(() => { + // The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every + // time — and the rows are ranked inside each group. The best row overall starts highlighted, so + // Enter takes the best match even when it sits in a later group. + const { visible, bestId } = useMemo(() => { const words = fold(query.trim()).split(/\s+/).filter(Boolean); - return groups - .map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) })) - .filter((g) => g.items.length > 0); + const out: CommandGroup[] = []; + let best: { id: string; rank: Rank } | null = null; + for (const g of groups) { + const ranked: { item: CommandItem; rank: Rank }[] = []; + for (const item of g.items) { + const rank = rankItem(item, g.label, words); + if (rank) ranked.push({ item, rank }); + } + if (ranked.length === 0) continue; + // `sort` is stable, so equal ranks keep the group's own order (modules in nav-rail order). + ranked.sort((a, b) => byRank(a.rank, b.rank)); + const top = ranked[0]!; + if (!best || byRank(top.rank, best.rank) < 0) best = { id: top.item.id, rank: top.rank }; + out.push({ ...g, items: ranked.map((r) => r.item) }); + } + return { visible: out, bestId: best?.id }; }, [groups, query]); // One flat list behind the grouped rendering — arrow keys walk results, not sections. const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]); - const activeId = flat[Math.min(active, flat.length - 1)]?.id; + const activeId = picked !== null && flat.some((i) => i.id === picked) ? picked : bestId; + const activeIndex = flat.findIndex((i) => i.id === activeId); // Keep the highlighted row in view when the arrows walk past the fold. useEffect(() => { activeRef.current?.scrollIntoView({ block: "nearest" }); }, [activeId]); + const summary = !query.trim() + ? "" + : flat.length === 0 + ? t("palette.empty") + : t("palette.results", { count: flat.length }); + const [announced, setAnnounced] = useState(""); + useEffect(() => { + if (!summary) { + setAnnounced(""); + return; + } + const timer = window.setTimeout(() => setAnnounced(summary), ANNOUNCE_DELAY_MS); + return () => window.clearTimeout(timer); + }, [summary]); + if (!open) return null; // Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands @@ -105,9 +164,10 @@ export default function CommandPalette({ e.preventDefault(); if (flat.length === 0) return; const step = e.key === "ArrowDown" ? 1 : -1; - setActive((i) => (Math.min(i, flat.length - 1) + step + flat.length) % flat.length); + const from = Math.max(activeIndex, 0); + setPicked(flat[(from + step + flat.length) % flat.length]!.id); } else if (e.key === "Enter") { - const item = flat[Math.min(active, flat.length - 1)]; + const item = flat[activeIndex]; if (item) { e.preventDefault(); choose(item); @@ -124,72 +184,91 @@ export default function CommandPalette({ value={query} onChange={(e) => { setQuery(e.target.value); - setActive(0); + setPicked(null); }} onKeyDown={onInputKey} placeholder={t("palette.placeholder")} aria-label={t("palette.placeholder")} // A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in - // the field, so a screen reader has to be told which row is current. + // the field, so a screen reader has to be told which row is current. `aria-expanded` + // follows the results: with nothing to choose from there is no popup to be in. role="combobox" - aria-expanded + aria-expanded={flat.length > 0} aria-controls={listId} aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined} className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted" /> -
- {flat.length === 0 ? ( -

{t("palette.empty")}

- ) : ( - visible.map((g) => ( -
-
- {g.label} -
- {g.items.map((item) => ( - - ))} -
- )) - )} +
+ {announced}
+ + {/* Stays mounted (hidden) while empty, so the input's aria-controls never names a missing + element; the "nothing matches" line sits OUTSIDE it, since a listbox may hold only options + and groups. */} + + {flat.length === 0 && ( +

{t("palette.empty")}

+ )} ); } diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 81d1efa5..36658622 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import { sortRows, cycleSort, type SortState } from "../../lib/columnSort"; import Pager, { hasPagerContent } from "./Pager"; import { PageToolbar } from "./PageShell"; -import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader"; +import { ColumnHead, alignClass } from "./tableHeader"; +import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -11,12 +12,12 @@ import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from // other modules can reuse it. Below `md` it falls back to a card list built from the same column // definitions. // -// The header row itself (sort cell + filter funnel/popover + the filter predicate) lives in -// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same -// headers without inheriting this component's ordering state. +// The header row itself (sort cell + filter funnel/popover) lives in `tableHeader.tsx`, the column +// contract and the filter predicate in `lib/tableColumns` — the playlist manager renders its own +// drag-reorderable body under the same headers without inheriting this component's ordering state. // Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working. -export type { Column } from "./tableHeader"; +export type { Column } from "../../lib/tableColumns"; interface Persisted { sort: SortState; diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index eae01500..d60901ac 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -23,6 +23,10 @@ enforced by `no-restricted-imports` in `eslint.config.js` — not by this README explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module supply it. +The direction is one-way from below, too: `lib/` may not import from `components/` — this directory +included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column` +/ `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import. + Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters), `KeyGate` (secure-messaging key setup), `plexCards`, `downloadRow`, `channelColumns` — all shared, none domain-free. diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx index 1cd87b43..87aa8f9d 100644 --- a/frontend/src/components/ui/tableHeader.tsx +++ b/frontend/src/components/ui/tableHeader.tsx @@ -4,10 +4,12 @@ import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { useScrollFade } from "../../lib/useScrollFade"; import { type SortState } from "../../lib/columnSort"; +import { isActive, type Column, type FilterMap } from "../../lib/tableColumns"; -// The column-header machinery — the column contract, the sortable header row with its filter -// funnel + popover, and the filter predicate — lifted OUT of DataTable so a non-DataTable body can -// render under the same headers. +// The column-header machinery — the sortable header row with its filter funnel + popover — lifted +// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract +// and the filter predicate it reads live in `lib/tableColumns` (lib helpers need them, and lib may +// not import from components). // // Why it lives here and not in DataTable: DataTable owns its filter/sort/page state internally, // while the playlist manager's body is a drag-reorderable list whose order is the persisted @@ -15,58 +17,6 @@ import { type SortState } from "../../lib/columnSort"; // playlist keep its stored order and still get real column headers. Same move as E4 S3's // ChannelLayoutGrid, which builds a card view straight from the `Column` render closures. -type ColumnFilter = - | { kind: "text"; get: (row: T) => string } - | { - kind: "select"; - options: { value: string; label: string }[]; - test: (row: T, value: string) => boolean; - } - | { - kind: "multi"; - options: { value: string; label: string }[]; - test: (row: T, values: string[]) => boolean; - }; - -export interface Column { - key: string; - header: string; - render: (row: T) => ReactNode; - align?: "left" | "right" | "center"; - width?: string; - sortable?: boolean; - sortValue?: (row: T) => string | number; - filter?: ColumnFilter; - // Keep the cell on one line so the column auto-sizes to its content (table-layout: auto). - nowrap?: boolean; - // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` - // omits the column; `cardLabel:false` shows the value without its header label. - cardPrimary?: boolean; - hideInCard?: boolean; - cardLabel?: boolean; -} - -export type FilterMap = Record; - -export function isActive(value: string | string[] | undefined): boolean { - return Array.isArray(value) ? value.length > 0 : !!value; -} - -/** Apply every column's own filter predicate. A column with no filter, or an empty value, passes. */ -export function filterRows(rows: T[], columns: Column[], filters: FilterMap): T[] { - return rows.filter((row) => - columns.every((col) => { - const f = col.filter; - if (!f) return true; - const val = filters[col.key]; - if (!isActive(val)) return true; - if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase()); - if (f.kind === "select") return f.test(row, String(val)); - return f.test(row, val as string[]); - }), - ); -} - export const alignClass = (a?: "left" | "right" | "center") => a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"; diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json index 16963d00..1cf52bad 100644 --- a/frontend/src/i18n/locales/en/palette.json +++ b/frontend/src/i18n/locales/en/palette.json @@ -2,6 +2,8 @@ "title": "Jump to…", "placeholder": "Search modules, channels, playlists…", "empty": "Nothing matches.", + "results": "{{count}} result", + "results_other": "{{count}} results", "theme": "Theme: {{name}}", "videoCount": "{{count}} video", "videoCount_other": "{{count}} videos", diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json index 3e00a1a6..21cca8b9 100644 --- a/frontend/src/i18n/locales/hu/palette.json +++ b/frontend/src/i18n/locales/hu/palette.json @@ -2,6 +2,8 @@ "title": "Ugrás…", "placeholder": "Modul, csatorna, lista keresése…", "empty": "Nincs találat.", + "results": "{{count}} találat", + "results_other": "{{count}} találat", "theme": "Téma: {{name}}", "videoCount": "{{count}} videó", "videoCount_other": "{{count}} videó", diff --git a/frontend/src/lib/channelsUrlState.dom.test.ts b/frontend/src/lib/channelsUrlState.dom.test.ts index 5ed18cdb..a91490ea 100644 --- a/frontend/src/lib/channelsUrlState.dom.test.ts +++ b/frontend/src/lib/channelsUrlState.dom.test.ts @@ -10,7 +10,7 @@ import { hasChannelsParams, type ChannelsUrlState, } from "./channelsUrlState"; -import type { FilterMap } from "../components/ui/tableHeader"; +import type { FilterMap } from "./tableColumns"; // A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and // an array (a multi column). diff --git a/frontend/src/lib/channelsUrlState.ts b/frontend/src/lib/channelsUrlState.ts index 14cb611a..2a930f9d 100644 --- a/frontend/src/lib/channelsUrlState.ts +++ b/frontend/src/lib/channelsUrlState.ts @@ -6,7 +6,7 @@ // emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex // rail search) and stays OUT of the shared contract. import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; -import { type FilterMap } from "../components/ui/tableHeader"; +import { type FilterMap } from "./tableColumns"; // The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the // raw provider SortState (healed against live columns only at the point of use), so the user's real diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index ee47c000..83a236a2 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -188,10 +188,6 @@ export function matches(e: KeyboardEvent, chord: string): boolean { return keyMatches(e, key); } -/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides - * whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped - * type over the scope's ids, so the table and the implementation cannot drift without tsc saying - * so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */ /** Test an event against ONE row's chords, by id. The escape hatch for a `handledLocally` row: it is * matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window), * but it must still go through `matches` or the table's `keys` become decoration. They had: the @@ -210,6 +206,10 @@ export function matchesShortcut( return !!row?.keys.some((k) => matches(e, k)); } +/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides + * whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped + * type over the scope's ids, so the table and the implementation cannot drift without tsc saying + * so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */ export function dispatch( e: KeyboardEvent, scope: S, diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index ab0a47f8..5cd77a2d 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -21,7 +21,7 @@ import { api, type Video } from "./api"; import { qk } from "./queryKeys"; import { patchCachedProgress } from "./progressCache"; import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube"; -import type { AutoMode, LoopMode } from "../components/playerModalControls"; +import type { AutoMode, LoopMode } from "./playerModes"; // How close to the end (seconds) counts as "finished" → auto-mark watched. const FINISH_MARGIN = 10; diff --git a/frontend/src/lib/playerModes.ts b/frontend/src/lib/playerModes.ts new file mode 100644 index 00000000..78b78cfc --- /dev/null +++ b/frontend/src/lib/playerModes.ts @@ -0,0 +1,11 @@ +// Persistent playback settings of the YouTube modal player (stored in users.preferences). +// Auto-advance = what plays when a video ends; loop = whether it repeats the current video ("one"), +// wraps the list at its ends ("all"), or neither ("off"). Both apply to any queued player (feed or +// playlist). +// +// Here rather than in `components/playerModalControls` because `lib/playerModalHooks` reads them +// too, and `lib/` may not import from `components/` (lint-enforced, see eslint.config.js). +export type AutoMode = "off" | "next" | "prev" | "random"; +export type LoopMode = "off" | "one" | "all"; +export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; +export const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; diff --git a/frontend/src/lib/playlistView.test.ts b/frontend/src/lib/playlistView.test.ts index 358a09d5..6367aedf 100644 --- a/frontend/src/lib/playlistView.test.ts +++ b/frontend/src/lib/playlistView.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyView, sameOrder } from "./playlistView"; -import type { Column } from "../components/ui/tableHeader"; +import type { Column } from "./tableColumns"; import type { Video } from "./api"; // The playlist's view derivation (E4 S4). What makes it worth a test: the column sort and the diff --git a/frontend/src/lib/sortOptions.ts b/frontend/src/lib/sortOptions.ts index 34038d2a..fefcb89c 100644 --- a/frontend/src/lib/sortOptions.ts +++ b/frontend/src/lib/sortOptions.ts @@ -1,4 +1,4 @@ -import { type Column } from "../components/ui/tableHeader"; +import { type Column } from "./tableColumns"; import { type SortOption } from "./columnSort"; /** The `SortOption[]` a table exposes for sorting — its sortable columns, labelled by header. Bridges diff --git a/frontend/src/lib/tableColumns.ts b/frontend/src/lib/tableColumns.ts new file mode 100644 index 00000000..fd8c2843 --- /dev/null +++ b/frontend/src/lib/tableColumns.ts @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; + +// The column contract every table-shaped view shares — `DataTable`, the playlist manager's hybrid +// table, the channel card grid — plus the filter predicate that reads it. +// +// It lives in `lib/`, not beside the header component in `components/ui/tableHeader`, because `lib/` +// helpers need it too (`sortOptions`, `useFilterState`, `channelsUrlState`) and the dependency +// direction is one-way: `lib/` may not import from `components/` (lint-enforced, see +// eslint.config.js). The contract is data + closures; nothing in it renders. + +type ColumnFilter = + | { kind: "text"; get: (row: T) => string } + | { + kind: "select"; + options: { value: string; label: string }[]; + test: (row: T, value: string) => boolean; + } + | { + kind: "multi"; + options: { value: string; label: string }[]; + test: (row: T, values: string[]) => boolean; + }; + +export interface Column { + key: string; + header: string; + render: (row: T) => ReactNode; + align?: "left" | "right" | "center"; + width?: string; + sortable?: boolean; + sortValue?: (row: T) => string | number; + filter?: ColumnFilter; + // Keep the cell on one line so the column auto-sizes to its content (table-layout: auto). + nowrap?: boolean; + // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` + // omits the column; `cardLabel:false` shows the value without its header label. + cardPrimary?: boolean; + hideInCard?: boolean; + cardLabel?: boolean; +} + +export type FilterMap = Record; + +export function isActive(value: string | string[] | undefined): boolean { + return Array.isArray(value) ? value.length > 0 : !!value; +} + +/** Apply every column's own filter predicate. A column with no filter, or an empty value, passes. */ +export function filterRows(rows: T[], columns: Column[], filters: FilterMap): T[] { + return rows.filter((row) => + columns.every((col) => { + const f = col.filter; + if (!f) return true; + const val = filters[col.key]; + if (!isActive(val)) return true; + if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase()); + if (f.kind === "select") return f.test(row, String(val)); + return f.test(row, val as string[]); + }), + ); +} diff --git a/frontend/src/lib/uiBoundary.contract.test.ts b/frontend/src/lib/uiBoundary.contract.test.ts index 6132915f..67673424 100644 --- a/frontend/src/lib/uiBoundary.contract.test.ts +++ b/frontend/src/lib/uiBoundary.contract.test.ts @@ -61,3 +61,36 @@ describe("components/ui/ boundary rule", () => { expect(found).toEqual([]); }); }); + +// E11 S3: one layer down, `lib/` may not import from `components/` at all. The type-only and the +// re-export spellings are in here on purpose — both are how a lib helper most easily leans on a +// component (`lib/sortOptions` imported `Column` from `ui/tableHeader` as a type). +describe("lib/ boundary rule", () => { + const AS_LIB = "src/lib/__boundary_probe.ts"; + + it("refuses components/ by every spelling: value, type-only, re-export, and from a nested lib dir", async () => { + const source = + `import F from "../components/Feed";\n` + + `import type { Column } from "../components/ui/tableHeader";\n` + + `export type { FilterMap } from "../components/ui/tableHeader";\n` + + `export default F;\n`; + const found = await restrictedIn(source, AS_LIB); + const nested = await restrictedIn( + `import M from "../../components/ui/Modal";\nexport default M;\n`, + "src/lib/api/__boundary_probe.ts", + ); + expect(found).toHaveLength(3); + expect(nested).toHaveLength(1); + expect([...found, ...nested].every((m) => m.includes("may not import from components/"))).toBe( + true, + ); + }); + + it("allows lib's own modules and the i18n setup", async () => { + const found = await restrictedIn( + `import { qk } from "./queryKeys";\nimport { api } from "./api";\nimport i18n from "../i18n";\nexport default [qk, api, i18n];\n`, + AS_LIB, + ); + expect(found).toEqual([]); + }); +}); diff --git a/frontend/src/lib/useFilterState.ts b/frontend/src/lib/useFilterState.ts index 5685a632..29ffd1d6 100644 --- a/frontend/src/lib/useFilterState.ts +++ b/frontend/src/lib/useFilterState.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from "react"; import { getAccountRaw, setAccountRaw } from "./storage"; import { useAccountRehydrate } from "./useAccountRehydrate"; -import type { FilterMap } from "../components/ui/tableHeader"; +import type { FilterMap } from "./tableColumns"; // The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the // caller (so a second view of the same rows — the channel manager's card grid, the top status chips