Resolves the 5 /code-review high findings on this branch: - a11y sweep: extend aria-label to the remaining icon-only Plex card buttons the first pass missed — PlexPosterCard Info, SeasonCard watched-toggle + add-season (EpisodeCard already had them). "Fix a principle everywhere, in one sweep." - new shared lib/parseId.ts `parsePositiveIntId`, used by BOTH readSelectedId (now guarding the stored ?pl fallback the same as the URL — was unguarded) and plexSubUrl's ?v=playlist decode; removes the hand-duplicated guard. - PAGE_KEY exported from navigationInit and imported by NavigationProvider (was defined in both). - hoist a `watchedLabel` const per card so title + aria-label can't drift. +8 tests (parseId unit + guarded-stored ?pl); full FE gate green (tsc/eslint/ prettier/knip, 317 vitest).
63 lines
2.3 KiB
TypeScript
63 lines
2.3 KiB
TypeScript
import type { SubviewUrl } from "../lib/history";
|
|
import { parsePositiveIntId } from "../lib/parseId";
|
|
|
|
// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the
|
|
// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component
|
|
// (and its lazy hls.js player) into the test graph.
|
|
export type Sub =
|
|
| { kind: "grid" }
|
|
| { kind: "show"; id: string }
|
|
| { kind: "season"; showId: string; seasonId: string }
|
|
| { kind: "player"; id: string; queue?: string[] }
|
|
| { kind: "info"; id: string }
|
|
| { kind: "person"; name: string }
|
|
| { kind: "playlist"; id: number };
|
|
|
|
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
|
|
// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person
|
|
// name is the whole tail after the first ":", so a name containing ":" round-trips.
|
|
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
|
key: "v",
|
|
encode: (v) => {
|
|
switch (v.kind) {
|
|
case "show":
|
|
return `show:${v.id}`;
|
|
case "season":
|
|
return `season:${v.showId}:${v.seasonId}`;
|
|
case "info":
|
|
return `info:${v.id}`;
|
|
case "person":
|
|
return `person:${v.name}`;
|
|
case "playlist":
|
|
return `playlist:${v.id}`;
|
|
default:
|
|
return null; // grid + the ephemeral player are not URL-mirrored
|
|
}
|
|
},
|
|
decode: (raw) => {
|
|
const i = raw.indexOf(":");
|
|
const kind = i === -1 ? raw : raw.slice(0, i);
|
|
const arg = i === -1 ? "" : raw.slice(i + 1);
|
|
switch (kind) {
|
|
case "show":
|
|
return arg ? { kind: "show", id: arg } : null;
|
|
case "info":
|
|
return arg ? { kind: "info", id: arg } : null;
|
|
case "person":
|
|
return arg ? { kind: "person", name: arg } : null;
|
|
case "playlist": {
|
|
// Positive-integer id only — a fractional/zero/negative ?v=playlist:<arg> would open a
|
|
// sub-view for a non-existent id (shares parsePositiveIntId with readSelectedId's ?pl guard).
|
|
const id = parsePositiveIntId(arg);
|
|
return id != null ? { kind: "playlist", id } : null;
|
|
}
|
|
case "season": {
|
|
const [showId, seasonId] = arg.split(":");
|
|
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
};
|