diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index cca253f1..6b295fa5 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -9,6 +9,8 @@ import { type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; +import { healSort } from "../lib/columnSort"; +import { plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -90,6 +92,13 @@ export default function PlexBrowse() { const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex(); const { setScope, setFilters, setPlaylistOpen } = usePlexActions(); 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({ kind: "grid" }); // 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]); 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", initialPageParam: 0, queryFn: ({ pageParam }) => api.plexLibrary({ scope, q: dq || undefined, - sort, + sort: sortState, show, filters, offset: pageParam as number, diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 9354ef2e..150d54af 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -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({ 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(() => getAccountRaw(LS.plexShow) ?? "all"); - const [sort, setSortState] = useState(() => 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(() => { + 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(() => getAccountRaw(LS.plexFilters) ?? "{}"); const filters = useMemo(() => { @@ -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); diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 2b1ac4b9..241cf970 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -4,11 +4,14 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; 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 { useDebounced } from "../lib/useDebounced"; import { useScrollFade } from "../lib/useScrollFade"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; +import SortControl from "./SortControl"; import { usePlex, usePlexActions } from "./PlexProvider"; // 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 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 ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; 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() }); onOpenPlaylist(pl.id); } - const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; + const { collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: qk.plexFacets(scope, show, facetKey), queryFn: () => api.plexFacets(scope, filters, show), @@ -114,7 +115,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const collections = collectionsQ.data?.collections ?? []; 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 patch = (p: Partial) => setFilters({ ...filters, ...p }); @@ -123,12 +124,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const clearAll = () => { setFilters(EMPTY_PLEX_FILTERS); setShow("all"); - setSort("title"); + setSort(PLEX_SORT_FALLBACK); }; - const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => - t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), - ); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; @@ -218,26 +217,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "sort", title: t("plex.filter.sort"), body: ( - <> - - {sorts.map((s) => ( - setSort(s)}> - {t(`plex.sort.${s}`)} - - ))} - -
- {(["asc", "desc"] as const).map((d) => ( - patch({ sortDir: d })} - > - {t(`plex.filter.dir.${d}`)} - - ))} -
- + setSort(next ?? PLEX_SORT_FALLBACK)} + label={t("plex.filter.sort")} + ascLabel={t("plex.filter.dir.asc")} + descLabel={t("plex.filter.dir.desc")} + /> ), }, ...(peopleActive diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 0a9eab46..2fea4492 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -3,6 +3,7 @@ // 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. import { req, beacon } from "./core"; +import { type SortState } from "../columnSort"; // Admin: result of testing the Plex server connection (also drives the library-picker). interface PlexSection { @@ -196,7 +197,6 @@ export interface PlexFilters { studios: string[]; // OR — from any selected studio collection?: string | null; // a single Plex collection (rating_key) collectionTitle?: string | null; // its title, for the active-filter chip - sortDir?: "asc" | "desc"; // sort direction (default desc) } export const EMPTY_PLEX_FILTERS: PlexFilters = { genres: [], @@ -275,7 +275,7 @@ export const plexApi = { plexLibrary: (p: { scope: string; // movie | show | both q?: string; - sort?: string; + sort?: SortState; show?: string; offset?: number; limit?: number; @@ -283,15 +283,14 @@ export const plexApi = { }): Promise => { const u = new URLSearchParams({ scope: p.scope }); 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.offset) u.set("offset", String(p.offset)); if (p.limit) u.set("limit", String(p.limit)); - const f = p.filters; - if (f) { - appendPlexFilters(u, f); - u.set("sort_dir", f.sortDir ?? "asc"); // default ascending - } + if (p.filters) appendPlexFilters(u, p.filters); return req(`/api/plex/library?${u.toString()}`); }, // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group diff --git a/frontend/src/lib/plexSort.ts b/frontend/src/lib/plexSort.ts new file mode 100644 index 00000000..d30d78ea --- /dev/null +++ b/frontend/src/lib/plexSort.ts @@ -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.`); 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)); +}