diff --git a/frontend/src/components/ColumnSortControl.tsx b/frontend/src/components/ColumnSortControl.tsx index d38d4d13..2599ac1c 100644 --- a/frontend/src/components/ColumnSortControl.tsx +++ b/frontend/src/components/ColumnSortControl.tsx @@ -1,11 +1,12 @@ -import { ArrowDown, ArrowUp } from "lucide-react"; import { type Column } from "./DataTable"; import { type SortState } from "../lib/columnSort"; +import { optionsFromColumns } from "../lib/sortOptions"; +import SortControl from "./SortControl"; -// A sort control for views without clickable column headers (the channel manager's card layout): -// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's -// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The -// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list. +// The headerless sort control for views without clickable column headers (the channel manager's and +// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the +// column-derived options and a "no sort" default — kept as its own component so the existing call +// sites stay untouched and table/cards keep sharing one `SortState`. export default function ColumnSortControl({ columns, sort, @@ -25,44 +26,15 @@ export default function ColumnSortControl({ ascLabel: string; descLabel: string; }) { - const sortable = columns.filter((c) => c.sortable && c.sortValue); - // A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped). - // `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the - // select falls back to showing "default" while a direction arrow claims a sort that isn't applied. - const active = sortable.some((c) => c.key === sort?.key) ? sort : null; return ( -
- - {active && ( - - )} -
+ ); } diff --git a/frontend/src/components/SortControl.tsx b/frontend/src/components/SortControl.tsx new file mode 100644 index 00000000..d4fd080f --- /dev/null +++ b/frontend/src/components/SortControl.tsx @@ -0,0 +1,71 @@ +import { type ReactNode } from "react"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { pickSort, type SortOption, type SortState } from "../lib/columnSort"; + +// The one sort control for every content surface (YT feed, Plex library, channel manager + its card +// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared +// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via +// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a +// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment +// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control. +export default function SortControl({ + options, + sort, + onChange, + label, + ascLabel, + descLabel, + defaultLabel, + trailing, +}: { + options: SortOption[]; + sort: SortState; + onChange: (next: SortState) => void; + /** Accessible name for the dropdown (e.g. "Sort"). */ + label: string; + ascLabel: string; + descLabel: string; + /** When set, a leading "no sort / original order" option is offered (tables). Omit for + * always-sorted views (feed/Plex), which never surface a null sort. */ + defaultLabel?: string; + /** Rendered after the direction toggle (e.g. the feed's reshuffle button). */ + trailing?: ReactNode; +}) { + // A stored sort can name an option this surface no longer offers; show it as unsorted rather than + // letting an arrow claim a sort that `sortRows`/the server won't actually apply. + const active = sort && options.some((o) => o.key === sort.key) ? sort : null; + const activeOpt = active ? options.find((o) => o.key === active.key) : undefined; + const showArrow = !!activeOpt && !activeOpt.directionless; + return ( +
+ + {showArrow && active && ( + + )} + {trailing} +
+ ); +} diff --git a/frontend/src/lib/columnSort.test.ts b/frontend/src/lib/columnSort.test.ts index 4219c176..4407896a 100644 --- a/frontend/src/lib/columnSort.test.ts +++ b/frontend/src/lib/columnSort.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { sortRows, parseSortState, cycleSort, type SortState } from "./columnSort"; +import { + sortRows, + parseSortState, + cycleSort, + pickSort, + healSort, + hydrateSort, + type SortOption, + type SortState, +} from "./columnSort"; interface Row { name: string; @@ -84,3 +93,80 @@ describe("cycleSort", () => { expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" }); }); }); + +const OPTIONS: SortOption[] = [ + { key: "date", label: "Date", defaultDir: "desc" }, + { key: "title", label: "Title" }, // defaultDir omitted → asc + { key: "shuffle", label: "Shuffle", directionless: true }, +]; + +describe("pickSort", () => { + it("starts a freshly-picked option at its defaultDir (asc fallback)", () => { + expect(pickSort(OPTIONS, null, "date")).toEqual({ key: "date", dir: "desc" }); + expect(pickSort(OPTIONS, null, "title")).toEqual({ key: "title", dir: "asc" }); + }); + + it("keeps the current direction when re-picking the same key", () => { + expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "date")).toEqual({ + key: "date", + dir: "asc", + }); + }); + + it("carries a stable dir for a directionless option and never shows a flip", () => { + expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "shuffle")).toEqual({ + key: "shuffle", + dir: "desc", + }); + }); + + it("clears to null on an empty key or an unknown option", () => { + expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "")).toBeNull(); + expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "ghost")).toBeNull(); + }); +}); + +describe("healSort", () => { + it("keeps a sort whose key is still offered", () => { + const s: SortState = { key: "title", dir: "asc" }; + expect(healSort(s, OPTIONS)).toBe(s); + }); + + it("collapses a sort naming an absent option to the fallback", () => { + expect(healSort({ key: "gone", dir: "asc" }, OPTIONS)).toBeNull(); + expect(healSort({ key: "gone", dir: "asc" }, OPTIONS, { key: "date", dir: "desc" })).toEqual({ + key: "date", + dir: "desc", + }); + }); +}); + +describe("hydrateSort", () => { + it("returns the fallback for missing storage", () => { + expect(hydrateSort(null, OPTIONS)).toBeNull(); + expect(hydrateSort(null, OPTIONS, { key: "date", dir: "desc" })).toEqual({ + key: "date", + dir: "desc", + }); + }); + + it("parses and heals a stored JSON state", () => { + expect(hydrateSort(JSON.stringify({ key: "title", dir: "asc" }), OPTIONS)).toEqual({ + key: "title", + dir: "asc", + }); + expect(hydrateSort(JSON.stringify({ key: "gone", dir: "asc" }), OPTIONS)).toBeNull(); + }); + + it("tolerates a legacy bare-key string, applying the option's defaultDir", () => { + expect(hydrateSort("date", OPTIONS)).toEqual({ key: "date", dir: "desc" }); + expect(hydrateSort("title", OPTIONS)).toEqual({ key: "title", dir: "asc" }); + }); + + it("falls back when a legacy bare-key names an unknown option", () => { + expect(hydrateSort("ghost", OPTIONS, { key: "date", dir: "desc" })).toEqual({ + key: "date", + dir: "desc", + }); + }); +}); diff --git a/frontend/src/lib/columnSort.ts b/frontend/src/lib/columnSort.ts index 7704685e..223add6c 100644 --- a/frontend/src/lib/columnSort.ts +++ b/frontend/src/lib/columnSort.ts @@ -40,3 +40,56 @@ export function cycleSort(current: SortState, key: string): SortState { if (current.dir === "asc") return { key, dir: "desc" }; return null; } + +// --- the source-agnostic sort vocabulary ------------------------------------------------- +// A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel +// manager, discovery) hands to the shared `SortControl`. A table derives it from its columns; +// the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into +// the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only. + +export interface SortOption { + key: string; + label: string; + /** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */ + directionless?: boolean; + /** The natural first direction when this option is freshly picked (defaults to `"asc"`). */ + defaultDir?: "asc" | "desc"; +} + +/** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click + * cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its + * `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */ +export function pickSort(options: SortOption[], prev: SortState, key: string): SortState { + if (!key) return null; + const opt = options.find((o) => o.key === key); + if (!opt) return null; + if (opt.directionless) return { key, dir: "desc" }; + if (prev?.key === key) return prev; + return { key, dir: opt.defaultDir ?? "asc" }; +} + +/** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show + * scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */ +export function healSort( + sort: SortState, + options: SortOption[], + fallback: SortState = null, +): SortState { + return sort && options.some((o) => o.key === sort.key) ? sort : fallback; +} + +/** Hydrate a persisted raw string into a healed `SortState`. Accepts the JSON `{key,dir}` shape and + * tolerates a legacy bare-key string (older builds stored just the key), falling back otherwise. */ +export function hydrateSort( + raw: string | null, + options: SortOption[], + fallback: SortState = null, +): SortState { + if (!raw) return fallback; + try { + return healSort(parseSortState(JSON.parse(raw)), options, fallback); + } catch { + const opt = options.find((o) => o.key === raw); + return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback; + } +} diff --git a/frontend/src/lib/sortOptions.ts b/frontend/src/lib/sortOptions.ts new file mode 100644 index 00000000..7d079d05 --- /dev/null +++ b/frontend/src/lib/sortOptions.ts @@ -0,0 +1,10 @@ +import { type Column } from "../components/tableHeader"; +import { type SortOption } from "./columnSort"; + +/** The `SortOption[]` a table exposes for sorting — its sortable columns, labelled by header. Bridges + * the `Column` contract to the source-agnostic sort vocabulary the shared `SortControl` consumes. */ +export function optionsFromColumns(columns: Column[]): SortOption[] { + return columns + .filter((c) => c.sortable && c.sortValue) + .map((c) => ({ key: c.key, label: c.header })); +} diff --git a/frontend/src/lib/useSortState.ts b/frontend/src/lib/useSortState.ts new file mode 100644 index 00000000..e459d9ec --- /dev/null +++ b/frontend/src/lib/useSortState.ts @@ -0,0 +1,24 @@ +import { useCallback, useState } from "react"; +import { getAccountRaw, setAccountRaw } from "./storage"; +import { hydrateSort, type SortOption, type SortState } from "./columnSort"; + +// The per-account persisted sort state, hydrated + healed against the current options — the plumbing +// the channel manager and channel discovery each hand-rolled (useState + parseSortState + a persist +// key). One home so a new sortable surface (Plex, a future table) gets persistence for free. +export function useSortState( + storageBase: string, + options: SortOption[], + fallback: SortState = null, +): [SortState, (next: SortState) => void] { + const [sort, setSort] = useState(() => + hydrateSort(getAccountRaw(storageBase), options, fallback), + ); + const set = useCallback( + (next: SortState) => { + setSort(next); + setAccountRaw(storageBase, JSON.stringify(next)); + }, + [storageBase], + ); + return [sort, set]; +}