refactor(plex): unify Plex sort onto SortControl + a single SortState (R13 S1)

Plex sort was a bespoke chip-pill row plus a separate asc/desc chip pair, with
the key in PlexState.sort and the direction hidden in PlexFilters.sortDir. Now:
- sort is one SortState {key,dir}; sortDir is dropped from PlexFilters (and from
  the facet-key destructure). The wire call still splits it into sort + sort_dir
  at the server boundary, so the backend is untouched.
- The chip sort-row is replaced by the shared SortControl (select + arrow) — the
  same idiom the feed and channel manager use. New lib/plexSort.ts owns the
  scope-dependent vocabulary (movies add duration) + the title/asc fallback.
- PlexProvider migrates the old storage in place: a legacy bare-key plexSort
  string plus the old filters.sortDir become the {key,dir} state, so an existing
  sort preference survives the upgrade.
- PlexBrowse and PlexSidebar heal the stored sort against the current scope's
  options, so a movie-only key (duration) left over after switching to shows
  can't ask the server for an unavailable sort.

Gate green: tsc clean, eslint 0 errors, prettier clean, vitest 93.
This commit is contained in:
2026-08-13 23:36:34 +02:00
parent 440bdafa74
commit e4bdc5ea6a
5 changed files with 88 additions and 44 deletions
+31 -6
View File
@@ -1,5 +1,7 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
@@ -17,7 +19,7 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: string;
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters;
q: string;
playlistOpen: number | null;
@@ -26,7 +28,7 @@ type PlexState = {
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: string) => void;
setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setPlaylistOpen: (id: number | null) => void;
@@ -35,7 +37,7 @@ interface PlexActions {
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: "title",
sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS,
q: "",
playlistOpen: null,
@@ -64,7 +66,30 @@ export function PlexProvider({ children }: { children: ReactNode }) {
return ["movie", "show", "both"].includes(v) ? v : "both";
});
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
// Sort is now a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort
// plus the direction that used to live in plexFilters.sortDir — so an existing sort preference
// survives the upgrade rather than snapping back to the default.
const [sort, setSortState] = useState<SortState>(() => {
const raw = getAccountRaw(LS.plexSort);
if (!raw) return PLEX_SORT_FALLBACK;
try {
const parsed = parseSortState(JSON.parse(raw));
if (parsed) return parsed;
} catch {
/* legacy bare-key string handled below */
}
let dir: "asc" | "desc" = "asc";
try {
if (
(JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir ===
"desc"
)
dir = "desc";
} catch {
/* ignore */
}
return { key: raw, dir };
});
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
const filters = useMemo<PlexFilters>(() => {
@@ -86,9 +111,9 @@ export function PlexProvider({ children }: { children: ReactNode }) {
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: string) => {
const setSort = useCallback((v: SortState) => {
setSortState(v);
setAccountRaw(LS.plexSort, v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
}, []);
const setFilters = useCallback((f: PlexFilters) => {
const s = JSON.stringify(f);