Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
89 lines
4.2 KiB
TypeScript
89 lines
4.2 KiB
TypeScript
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
|
|
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
|
|
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
|
|
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
|
|
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
|
// 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 "./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
|
|
// preference round-trips through the URL.
|
|
export interface ChannelsUrlState {
|
|
view: string;
|
|
sort: SortState;
|
|
filters: FilterMap;
|
|
}
|
|
|
|
const KEYS = ["ctab", "csort", "cf"] as const;
|
|
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
|
|
|
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
|
// sort here (fallback null), so any sort emits — no default short-circuit needed.
|
|
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
|
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
|
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
|
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
|
|
// no `cf`.
|
|
function encodeFilters(map: FilterMap): string | null {
|
|
const clean: FilterMap = {};
|
|
for (const [k, v] of Object.entries(map)) {
|
|
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
|
}
|
|
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
|
}
|
|
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
|
// heals to empty (same discipline as useFilterState's storage hydrate).
|
|
function decodeFilters(raw: string): FilterMap {
|
|
try {
|
|
const v = JSON.parse(raw);
|
|
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
|
const out: FilterMap = {};
|
|
for (const [k, val] of Object.entries(v)) {
|
|
if (typeof val === "string") out[k] = val;
|
|
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
|
out[k] = val as string[];
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
|
|
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
|
|
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
|
|
* re-mirrors on return). Feed the result into urlWithParams(). */
|
|
export function channelsParamUpdates(
|
|
state: ChannelsUrlState | null,
|
|
): Record<string, string | null> {
|
|
if (!state) return { ctab: null, csort: null, cf: null };
|
|
return {
|
|
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
|
csort: encodeSortParam(state.sort),
|
|
cf: encodeFilters(state.filters),
|
|
};
|
|
}
|
|
|
|
/** True if the query string carries any manager content key we recognize (a shared manager link). */
|
|
export function hasChannelsParams(params: URLSearchParams): boolean {
|
|
return KEYS.some((k) => params.has(k));
|
|
}
|
|
|
|
/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an
|
|
* absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */
|
|
export function channelsParamsToState(
|
|
params: URLSearchParams,
|
|
base: ChannelsUrlState,
|
|
): ChannelsUrlState {
|
|
const tab = params.get("ctab");
|
|
const csort = params.get("csort");
|
|
return {
|
|
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
|
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
|
|
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
|
};
|
|
}
|