From d0f5559c499e50ff8a222e439f4937cb8e074d85 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 18:30:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20S3=20toolbar=20relocation=20?= =?UTF-8?q?=E2=80=94=20scope=20to=20header,=20sort=20above=20the=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the two "what am I browsing" controls out of the filter sidebar to where they're always in reach, matching the Feed's layout: - Library scope (both/movie/show) → a compact SegmentedToggle in the header next to the search box (new PlexScopeToggle, reads the same provider state). - Sort → a SortControl in the row above the cards (into the PageToolbar band, count on the left, sort on the right), like the Feed. Sidebar cleanup: drop the `scope` and `sort` island cards; the panel badge now counts only the filters still in the panel (expanded filters + watch-state); remove the now-unused sort imports. panelLayout drops both from the default plex order (stale saved-layout keys are auto-filtered, so existing layouts degrade cleanly). usePlexActiveFilters no longer emits a sort chip — sort is an always-visible control now, not a removable narrowing (same as the Feed). FE-only, no backend/migration. typecheck + eslint clean; 171 vitest green; self-E2E verified (header scope drives the grid, sort control reorders, sidebar no longer shows the two cards, no sort chip). --- frontend/src/components/Header.tsx | 4 ++ frontend/src/components/PlexBrowse.tsx | 20 ++++++-- frontend/src/components/PlexScopeToggle.tsx | 26 ++++++++++ frontend/src/components/PlexSidebar.tsx | 51 ++----------------- .../src/components/usePlexActiveFilters.ts | 23 +++------ frontend/src/lib/panelLayout.ts | 2 - 6 files changed, 60 insertions(+), 66 deletions(-) create mode 100644 frontend/src/components/PlexScopeToggle.tsx diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8a049fba..bfa6fbca 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -6,6 +6,7 @@ import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import ModuleName from "./ModuleName"; import SearchBar from "./SearchBar"; import PlexSearchBox from "./PlexSearchBox"; +import PlexScopeToggle from "./PlexScopeToggle"; import SyncStatus from "./SyncStatus"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -103,6 +104,9 @@ export default function Header({ canNext={multiModule} /> {search} + {/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from + the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */} + {page === "plex" && me.plex_enabled && } {/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other pills rather than pinned to the far right: on a wide window that just strands it. Only on the two modules it says anything about: the feed those subscriptions fill, and the manager diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 13262e2b..1df36a0d 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -10,7 +10,8 @@ import { } from "react"; import { qk } from "../lib/queryKeys"; import { healPlexField } from "../lib/plexSearch"; -import { healPlexSort } from "../lib/plexSort"; +import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import SortControl from "./SortControl"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -92,7 +93,7 @@ export default function PlexBrowse() { // search term (q) comes from the shared header box; the rail hands off a playlist to open via the // shared `playlistOpen` slot, which we consume into a history subview then clear. const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex(); - const { setScope, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions(); + const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions(); const dq = useDebounced(q.trim(), 350); // Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per // narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort. @@ -101,6 +102,7 @@ export default function PlexBrowse() { // 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 = healPlexSort(sort, scope); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); const sub = useHistorySubview({ kind: "grid" }); @@ -346,7 +348,7 @@ export default function PlexBrowse() { {/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */} -
+

{browseQ.isLoading ? " " @@ -354,6 +356,18 @@ export default function PlexBrowse() { ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}

+ {/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */} +
+ {t("plex.filter.sort")} + setSort(next ?? PLEX_SORT_FALLBACK)} + label={t("plex.filter.sort")} + ascLabel={t("plex.filter.dir.asc")} + descLabel={t("plex.filter.dir.desc")} + /> +
diff --git a/frontend/src/components/PlexScopeToggle.tsx b/frontend/src/components/PlexScopeToggle.tsx new file mode 100644 index 00000000..93e58a78 --- /dev/null +++ b/frontend/src/components/PlexScopeToggle.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from "react-i18next"; +import SegmentedToggle from "./SegmentedToggle"; +import { usePlex, usePlexActions } from "./PlexProvider"; + +// The Plex library scope (both | movie | show) as a compact header control, sitting next to the +// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same +// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared +// SegmentedToggle and the existing plex.filter.scopeOpt.* labels. +const SCOPES = ["both", "movie", "show"] as const; + +export default function PlexScopeToggle() { + const { t } = useTranslation(); + const { scope } = usePlex(); + const { setScope } = usePlexActions(); + return ( +
+ ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))} + value={scope} + onChange={setScope} + /> +
+ ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 236865cf..d4035efa 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -4,13 +4,11 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, plexFilterCount, type PlexFilters } from "../lib/api"; -import { healPlexSort, 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 SegmentedToggle from "./SegmentedToggle"; import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import { usePlexClearFilters } from "./usePlexActiveFilters"; @@ -66,14 +64,8 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and // the header search box); this rail is the primary editor of them. Opening a playlist is a // hand-off to PlexBrowse via the shared `playlistOpen` slot. - const { scope, show, sort, filters } = usePlex(); - const { - setScope, - setShow, - setSort, - setFilters, - setPlaylistOpen: onOpenPlaylist, - } = usePlexActions(); + const { scope, show, filters } = usePlex(); + const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions(); const collFade = useScrollFade(); const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); @@ -106,10 +98,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const collections = collectionsQ.data?.collections ?? []; const fCount = plexFilterCount(filters); - // Heal against the current scope so a stale out-of-scope key (e.g. movie "duration" after switching - // to shows) doesn't inflate the active-filter count while the control + grid show the default. - const healedSort = healPlexSort(sort, scope); - const activeCount = fCount + (show !== "all" ? 1 : 0) + (healedSort.key !== "title" ? 1 : 0); + // Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only + // the filters that still live in this panel (expanded filters + watch-state). + const activeCount = fCount + (show !== "all" ? 1 : 0); const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); @@ -117,7 +108,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]; const clearAll = usePlexClearFilters(); - 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; @@ -128,23 +118,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved // layout and lets the user reorder / hide / collapse them. const items: PanelItem[] = [ - { - id: "scope", - title: t("plex.filter.scope"), - body: ( - ({ - value: s, - label: t(`plex.filter.scopeOpt.${s}`), - }))} - value={scope} - onChange={setScope} - /> - ), - }, { id: "playlists", title: t("plex.playlist.section"), @@ -199,20 +172,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl /> ), }, - { - id: "sort", - title: t("plex.filter.sort"), - body: ( - 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/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index 4b12e019..5283cd44 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -1,19 +1,20 @@ import { useTranslation } from "react-i18next"; import { type ActiveFilter } from "../lib/useActiveFilters"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; -import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { usePlex, usePlexActions } from "./PlexProvider"; -// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state + -// sort) into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid. +// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state) +// into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid. // Plex thereby gets the same at-a-glance "what's narrowing this" row — with per-chip removal — that // the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse // the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so -// removing one keeps the rest (same shape as the feed's tag/channel chips). Lives here, not in lib, -// because it reads the Plex provider (lib stays provider-free, like useActiveFilters). +// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here — +// it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because +// it reads the Plex provider (lib stays provider-free, like useActiveFilters). export function usePlexActiveFilters(): ActiveFilter[] { - const { scope, show, sort, filters } = usePlex(); - const { setShow, setSort, setFilters } = usePlexActions(); + const { show, filters } = usePlex(); + const { setShow, setFilters } = usePlexActions(); const { t } = useTranslation(); const patch = (p: Partial) => setFilters({ ...filters, ...p }); const out: ActiveFilter[] = []; @@ -108,14 +109,6 @@ export function usePlexActiveFilters(): ActiveFilter[] { remove: () => setShow("all"), }); } - const healed = healPlexSort(sort, scope); - if (healed.key !== "title") { - out.push({ - key: "sort", - label: t("plex.sort." + healed.key), - remove: () => setSort(PLEX_SORT_FALLBACK), - }); - } return out; } diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts index a8aad5c9..ed443e84 100644 --- a/frontend/src/lib/panelLayout.ts +++ b/frontend/src/lib/panelLayout.ts @@ -18,10 +18,8 @@ export interface PanelLayout { const PANEL_ORDER: Record = { feed: ["savedviews", "date", "channel", "tags", "language", "topic"], plex: [ - "scope", "playlists", "watch", - "sort", "people", "collection", "rating",