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:
@@ -9,6 +9,8 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { healSort } from "../lib/columnSort";
|
||||||
|
import { plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +92,13 @@ export default function PlexBrowse() {
|
|||||||
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
||||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
|
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||||
|
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||||
|
const sortState = healSort(
|
||||||
|
sort,
|
||||||
|
plexSortOptions(scope, () => ""),
|
||||||
|
PLEX_SORT_FALLBACK,
|
||||||
|
);
|
||||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||||
|
|
||||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||||
@@ -141,14 +150,14 @@ export default function PlexBrowse() {
|
|||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: qk.plexLibrary(scope, dq, sort, show, filters),
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
|
||||||
enabled: !!scope && sub.view.kind === "grid",
|
enabled: !!scope && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.plexLibrary({
|
api.plexLibrary({
|
||||||
scope,
|
scope,
|
||||||
q: dq || undefined,
|
q: dq || undefined,
|
||||||
sort,
|
sort: sortState,
|
||||||
show,
|
show,
|
||||||
filters,
|
filters,
|
||||||
offset: pageParam as number,
|
offset: pageParam as number,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
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";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
|
|
||||||
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
|
// 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 = {
|
type PlexState = {
|
||||||
scope: string; // movie | show | both (unified cross-library scope)
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
show: string;
|
show: string;
|
||||||
sort: string;
|
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
q: string;
|
q: string;
|
||||||
playlistOpen: number | null;
|
playlistOpen: number | null;
|
||||||
@@ -26,7 +28,7 @@ type PlexState = {
|
|||||||
interface PlexActions {
|
interface PlexActions {
|
||||||
setScope: (v: string) => void;
|
setScope: (v: string) => void;
|
||||||
setShow: (v: string) => void;
|
setShow: (v: string) => void;
|
||||||
setSort: (v: string) => void;
|
setSort: (v: SortState) => void;
|
||||||
setFilters: (f: PlexFilters) => void;
|
setFilters: (f: PlexFilters) => void;
|
||||||
setQ: (q: string) => void;
|
setQ: (q: string) => void;
|
||||||
setPlaylistOpen: (id: number | null) => void;
|
setPlaylistOpen: (id: number | null) => void;
|
||||||
@@ -35,7 +37,7 @@ interface PlexActions {
|
|||||||
const StateContext = createContext<PlexState>({
|
const StateContext = createContext<PlexState>({
|
||||||
scope: "both",
|
scope: "both",
|
||||||
show: "all",
|
show: "all",
|
||||||
sort: "title",
|
sort: PLEX_SORT_FALLBACK,
|
||||||
filters: EMPTY_PLEX_FILTERS,
|
filters: EMPTY_PLEX_FILTERS,
|
||||||
q: "",
|
q: "",
|
||||||
playlistOpen: null,
|
playlistOpen: null,
|
||||||
@@ -64,7 +66,30 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||||
});
|
});
|
||||||
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
|
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).
|
// 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 [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
|
||||||
const filters = useMemo<PlexFilters>(() => {
|
const filters = useMemo<PlexFilters>(() => {
|
||||||
@@ -86,9 +111,9 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
setShowState(v);
|
setShowState(v);
|
||||||
setAccountRaw(LS.plexShow, v);
|
setAccountRaw(LS.plexShow, v);
|
||||||
}, []);
|
}, []);
|
||||||
const setSort = useCallback((v: string) => {
|
const setSort = useCallback((v: SortState) => {
|
||||||
setSortState(v);
|
setSortState(v);
|
||||||
setAccountRaw(LS.plexSort, v);
|
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||||
}, []);
|
}, []);
|
||||||
const setFilters = useCallback((f: PlexFilters) => {
|
const setFilters = useCallback((f: PlexFilters) => {
|
||||||
const s = JSON.stringify(f);
|
const s = JSON.stringify(f);
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
|
import { healSort } from "../lib/columnSort";
|
||||||
|
import { plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
import SortControl from "./SortControl";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||||
@@ -24,8 +27,6 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
|
||||||
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
|
||||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||||
@@ -95,7 +96,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||||
onOpenPlaylist(pl.id);
|
onOpenPlaylist(pl.id);
|
||||||
}
|
}
|
||||||
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: qk.plexFacets(scope, show, facetKey),
|
queryKey: qk.plexFacets(scope, show, facetKey),
|
||||||
queryFn: () => api.plexFacets(scope, filters, show),
|
queryFn: () => api.plexFacets(scope, filters, show),
|
||||||
@@ -114,7 +115,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const collections = collectionsQ.data?.collections ?? [];
|
const collections = collectionsQ.data?.collections ?? [];
|
||||||
|
|
||||||
const fCount = plexFilterCount(filters);
|
const fCount = plexFilterCount(filters);
|
||||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
|
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort && sort.key !== "title" ? 1 : 0);
|
||||||
const anyActive = activeCount > 0;
|
const anyActive = activeCount > 0;
|
||||||
|
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
@@ -123,12 +124,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
setFilters(EMPTY_PLEX_FILTERS);
|
setFilters(EMPTY_PLEX_FILTERS);
|
||||||
setShow("all");
|
setShow("all");
|
||||||
setSort("title");
|
setSort(PLEX_SORT_FALLBACK);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
|
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||||
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
|
|
||||||
);
|
|
||||||
const durBucketKey = DURATION_BUCKETS.find(
|
const durBucketKey = DURATION_BUCKETS.find(
|
||||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||||
)?.key;
|
)?.key;
|
||||||
@@ -218,26 +217,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "sort",
|
id: "sort",
|
||||||
title: t("plex.filter.sort"),
|
title: t("plex.filter.sort"),
|
||||||
body: (
|
body: (
|
||||||
<>
|
<SortControl
|
||||||
<ChipRow>
|
options={sortOptions}
|
||||||
{sorts.map((s) => (
|
sort={healSort(sort, sortOptions, PLEX_SORT_FALLBACK)}
|
||||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||||
{t(`plex.sort.${s}`)}
|
label={t("plex.filter.sort")}
|
||||||
</Chip>
|
ascLabel={t("plex.filter.dir.asc")}
|
||||||
))}
|
descLabel={t("plex.filter.dir.desc")}
|
||||||
</ChipRow>
|
/>
|
||||||
<div className="mt-1.5 flex gap-1">
|
|
||||||
{(["asc", "desc"] as const).map((d) => (
|
|
||||||
<Chip
|
|
||||||
key={d}
|
|
||||||
active={(filters.sortDir ?? "asc") === d}
|
|
||||||
onClick={() => patch({ sortDir: d })}
|
|
||||||
>
|
|
||||||
{t(`plex.filter.dir.${d}`)}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(peopleActive
|
...(peopleActive
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// two-way watch-sync link. Self-contained — its types reference only each other, and its methods
|
// two-way watch-sync link. Self-contained — its types reference only each other, and its methods
|
||||||
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
|
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
|
||||||
import { req, beacon } from "./core";
|
import { req, beacon } from "./core";
|
||||||
|
import { type SortState } from "../columnSort";
|
||||||
|
|
||||||
// Admin: result of testing the Plex server connection (also drives the library-picker).
|
// Admin: result of testing the Plex server connection (also drives the library-picker).
|
||||||
interface PlexSection {
|
interface PlexSection {
|
||||||
@@ -196,7 +197,6 @@ export interface PlexFilters {
|
|||||||
studios: string[]; // OR — from any selected studio
|
studios: string[]; // OR — from any selected studio
|
||||||
collection?: string | null; // a single Plex collection (rating_key)
|
collection?: string | null; // a single Plex collection (rating_key)
|
||||||
collectionTitle?: string | null; // its title, for the active-filter chip
|
collectionTitle?: string | null; // its title, for the active-filter chip
|
||||||
sortDir?: "asc" | "desc"; // sort direction (default desc)
|
|
||||||
}
|
}
|
||||||
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||||
genres: [],
|
genres: [],
|
||||||
@@ -275,7 +275,7 @@ export const plexApi = {
|
|||||||
plexLibrary: (p: {
|
plexLibrary: (p: {
|
||||||
scope: string; // movie | show | both
|
scope: string; // movie | show | both
|
||||||
q?: string;
|
q?: string;
|
||||||
sort?: string;
|
sort?: SortState;
|
||||||
show?: string;
|
show?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -283,15 +283,14 @@ export const plexApi = {
|
|||||||
}): Promise<PlexUnifiedResult> => {
|
}): Promise<PlexUnifiedResult> => {
|
||||||
const u = new URLSearchParams({ scope: p.scope });
|
const u = new URLSearchParams({ scope: p.scope });
|
||||||
if (p.q) u.set("q", p.q);
|
if (p.q) u.set("q", p.q);
|
||||||
if (p.sort) u.set("sort", p.sort);
|
if (p.sort) {
|
||||||
|
u.set("sort", p.sort.key);
|
||||||
|
u.set("sort_dir", p.sort.dir);
|
||||||
|
}
|
||||||
if (p.show && p.show !== "all") u.set("show", p.show);
|
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||||
if (p.offset) u.set("offset", String(p.offset));
|
if (p.offset) u.set("offset", String(p.offset));
|
||||||
if (p.limit) u.set("limit", String(p.limit));
|
if (p.limit) u.set("limit", String(p.limit));
|
||||||
const f = p.filters;
|
if (p.filters) appendPlexFilters(u, p.filters);
|
||||||
if (f) {
|
|
||||||
appendPlexFilters(u, f);
|
|
||||||
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
|
|
||||||
}
|
|
||||||
return req(`/api/plex/library?${u.toString()}`);
|
return req(`/api/plex/library?${u.toString()}`);
|
||||||
},
|
},
|
||||||
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { type SortOption, type SortState } from "./columnSort";
|
||||||
|
|
||||||
|
// Plex's sort vocabulary, source-agnostic like the feed's — a `SortOption[]` fed to the shared
|
||||||
|
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
|
||||||
|
// call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary.
|
||||||
|
|
||||||
|
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||||
|
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
||||||
|
|
||||||
|
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
|
||||||
|
export function plexSortKeys(scope: string): string[] {
|
||||||
|
return [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The historical default: `sort=title`, `sort_dir=asc`. */
|
||||||
|
export const PLEX_SORT_FALLBACK: SortState = { key: "title", dir: "asc" };
|
||||||
|
|
||||||
|
/** Labelled, alpha-ordered options for the SortControl. `label` resolves each key (i18n
|
||||||
|
* `plex.sort.<key>`); pass a no-op label when only the key set matters (healing). */
|
||||||
|
export function plexSortOptions(scope: string, label: (key: string) => string): SortOption[] {
|
||||||
|
return plexSortKeys(scope)
|
||||||
|
.map((key) => ({ key, label: label(key) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user