feat(plex): S3 toolbar relocation — scope to header, sort above the cards
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).
This commit is contained in:
@@ -6,6 +6,7 @@ import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
|||||||
import ModuleName from "./ModuleName";
|
import ModuleName from "./ModuleName";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import PlexSearchBox from "./PlexSearchBox";
|
import PlexSearchBox from "./PlexSearchBox";
|
||||||
|
import PlexScopeToggle from "./PlexScopeToggle";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
@@ -103,6 +104,9 @@ export default function Header({
|
|||||||
canNext={multiModule}
|
canNext={multiModule}
|
||||||
/>
|
/>
|
||||||
{search}
|
{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 && <PlexScopeToggle />}
|
||||||
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
{/* 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
|
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
|
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { healPlexField } from "../lib/plexSearch";
|
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 { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
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
|
// 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.
|
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
||||||
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
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);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
// 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.
|
// 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
|
// 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.
|
// 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 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.
|
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||||
const fieldState = healPlexField(field, scope);
|
const fieldState = healPlexField(field, scope);
|
||||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
const sub = useHistorySubview<Sub>({ 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
|
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||||
while only the poster grid scrolls. */}
|
while only the poster grid scrolls. */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
|
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
{browseQ.isLoading
|
{browseQ.isLoading
|
||||||
? " "
|
? " "
|
||||||
@@ -354,6 +356,18 @@ export default function PlexBrowse() {
|
|||||||
? t("plex.searchCount", { count: total })
|
? t("plex.searchCount", { count: total })
|
||||||
: t("plex.count", { count: total })}
|
: t("plex.count", { count: total })}
|
||||||
</p>
|
</p>
|
||||||
|
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
|
||||||
|
<SortControl
|
||||||
|
options={sortOptions}
|
||||||
|
sort={sortState}
|
||||||
|
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||||
|
label={t("plex.filter.sort")}
|
||||||
|
ascLabel={t("plex.filter.dir.asc")}
|
||||||
|
descLabel={t("plex.filter.dir.desc")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="pointer-events-auto shrink-0">
|
||||||
|
<SegmentedToggle
|
||||||
|
size="md"
|
||||||
|
ariaLabel={t("plex.filter.scope")}
|
||||||
|
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,11 @@ 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, plexFilterCount, type PlexFilters } from "../lib/api";
|
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 { 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 SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
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
|
// 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
|
// 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.
|
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||||
const { scope, show, sort, filters } = usePlex();
|
const { scope, show, filters } = usePlex();
|
||||||
const {
|
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
||||||
setScope,
|
|
||||||
setShow,
|
|
||||||
setSort,
|
|
||||||
setFilters,
|
|
||||||
setPlaylistOpen: onOpenPlaylist,
|
|
||||||
} = usePlexActions();
|
|
||||||
const collFade = useScrollFade();
|
const collFade = useScrollFade();
|
||||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
const [newPlaylist, setNewPlaylist] = useState("");
|
||||||
@@ -106,10 +98,9 @@ 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);
|
||||||
// Heal against the current scope so a stale out-of-scope key (e.g. movie "duration" after switching
|
// Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only
|
||||||
// to shows) doesn't inflate the active-filter count while the control + grid show the default.
|
// the filters that still live in this panel (expanded filters + watch-state).
|
||||||
const healedSort = healPlexSort(sort, scope);
|
const activeCount = fCount + (show !== "all" ? 1 : 0);
|
||||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (healedSort.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 });
|
||||||
@@ -117,7 +108,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
||||||
const clearAll = usePlexClearFilters();
|
const clearAll = usePlexClearFilters();
|
||||||
|
|
||||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
|
||||||
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;
|
||||||
@@ -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
|
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
|
||||||
// layout and lets the user reorder / hide / collapse them.
|
// layout and lets the user reorder / hide / collapse them.
|
||||||
const items: PanelItem[] = [
|
const items: PanelItem[] = [
|
||||||
{
|
|
||||||
id: "scope",
|
|
||||||
title: t("plex.filter.scope"),
|
|
||||||
body: (
|
|
||||||
<SegmentedToggle
|
|
||||||
ariaLabel={t("plex.filter.scope")}
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
options={(["both", "movie", "show"] as const).map((s) => ({
|
|
||||||
value: s,
|
|
||||||
label: t(`plex.filter.scopeOpt.${s}`),
|
|
||||||
}))}
|
|
||||||
value={scope}
|
|
||||||
onChange={setScope}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "playlists",
|
id: "playlists",
|
||||||
title: t("plex.playlist.section"),
|
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: (
|
|
||||||
<SortControl
|
|
||||||
options={sortOptions}
|
|
||||||
sort={healedSort}
|
|
||||||
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
|
||||||
label={t("plex.filter.sort")}
|
|
||||||
ascLabel={t("plex.filter.dir.asc")}
|
|
||||||
descLabel={t("plex.filter.dir.desc")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...(peopleActive
|
...(peopleActive
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type ActiveFilter } from "../lib/useActiveFilters";
|
import { type ActiveFilter } from "../lib/useActiveFilters";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
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";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state +
|
// 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.
|
// 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
|
// 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 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
|
// 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,
|
// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here —
|
||||||
// because it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
// 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[] {
|
export function usePlexActiveFilters(): ActiveFilter[] {
|
||||||
const { scope, show, sort, filters } = usePlex();
|
const { show, filters } = usePlex();
|
||||||
const { setShow, setSort, setFilters } = usePlexActions();
|
const { setShow, setFilters } = usePlexActions();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
const out: ActiveFilter[] = [];
|
const out: ActiveFilter[] = [];
|
||||||
@@ -108,14 +109,6 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
|||||||
remove: () => setShow("all"),
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ export interface PanelLayout {
|
|||||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||||
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
|
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
|
||||||
plex: [
|
plex: [
|
||||||
"scope",
|
|
||||||
"playlists",
|
"playlists",
|
||||||
"watch",
|
"watch",
|
||||||
"sort",
|
|
||||||
"people",
|
"people",
|
||||||
"collection",
|
"collection",
|
||||||
"rating",
|
"rating",
|
||||||
|
|||||||
Reference in New Issue
Block a user