feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav; its sections are reorderable "islands" (drag / hide / per-group collapse) with a per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge fade. The nav rail becomes a matching floating rounded card. - New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook. - Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps its storage key; plex/playlists get their own; persisted per account). - Lift the Playlists rail to App level (selected playlist is now App state) so it floats and collapses like the filter rails; remove the old CollapsedFilterRail. - The floating top header's fixed offset accounts for the panels' margins.
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react";
|
||||
import CollapsedFilterRail from "./CollapsedFilterRail";
|
||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
|
||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
||||
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
||||
// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State
|
||||
// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the
|
||||
// backend so the sidebar only offers what the library actually contains.
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
|
||||
// the backend so the panel only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
@@ -22,17 +23,17 @@ type Props = {
|
||||
filters: PlexFilters;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
onOpenPlaylist: (id: number) => void;
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||
// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime).
|
||||
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;
|
||||
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
{ key: "short", min: null, max: 90 * 60 },
|
||||
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
||||
@@ -49,12 +50,15 @@ export default function PlexSidebar({
|
||||
filters,
|
||||
setFilters,
|
||||
onOpenPlaylist,
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
async function createPlaylist() {
|
||||
const title = newPlaylist.trim();
|
||||
@@ -64,29 +68,18 @@ export default function PlexSidebar({
|
||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
// Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters
|
||||
// (faceted search) — so picking one filter narrows what the others offer. Refetches on any change.
|
||||
// Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets,
|
||||
// so keying on the whole `filters` object would re-run the heavy facet computation on every
|
||||
// sort-direction toggle (or chip-title change) for an identical request.
|
||||
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
||||
const facetsQ = useQuery({
|
||||
queryKey: ["plex-facets", scope, show, facetKey],
|
||||
queryFn: () => api.plexFacets(scope, filters, show),
|
||||
enabled: !!scope,
|
||||
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker)
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
// Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the
|
||||
// unified scope has no single library). Only fetched while no collection is active (else the chip
|
||||
// shows instead). Applying one sets the collection filter; the actual filtering already flows through
|
||||
// /library + /facets by rating_key.
|
||||
const [collSearch, setCollSearch] = useState("");
|
||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||
const collectionsQ = useQuery({
|
||||
// "union" disambiguates from the editor's per-library ["plex-collections", <plex_key>] key (a search
|
||||
// term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working.
|
||||
queryKey: ["plex-collections", "union", collSearchDeb],
|
||||
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
|
||||
enabled: !filters.collection,
|
||||
@@ -106,112 +99,83 @@ export default function PlexSidebar({
|
||||
setSort("title");
|
||||
};
|
||||
|
||||
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
|
||||
// Ordered alphabetically by their localized label.
|
||||
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
|
||||
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
|
||||
);
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0;
|
||||
|
||||
if (collapsed) {
|
||||
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 glass overflow-y-auto p-4 space-y-4">
|
||||
{/* Collapse control + active-filter count — mirrors the feed Sidebar so the Plex filter panel
|
||||
can be collapsed from the Plex page too (the collapsed rail + shared state already existed). */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.collapsePanel")}
|
||||
aria-label={t("sidebar.collapsePanel")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
|
||||
{activeCount > 0 && (
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count: activeCount })}
|
||||
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope — one unified library, filtered to movies, shows, or both. */}
|
||||
<Section label={t("plex.filter.scope")}>
|
||||
<div className="flex gap-1">
|
||||
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
|
||||
// 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: (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(["both", "movie", "show"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScope(s)}
|
||||
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
|
||||
className={`min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate transition ${
|
||||
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
|
||||
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
|
||||
{t(`plex.filter.scopeOpt.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
|
||||
a filter. Available in any library. */}
|
||||
<Section label={t("plex.playlist.section")}>
|
||||
<div className="mb-1.5 flex gap-1.5">
|
||||
<input
|
||||
value={newPlaylist}
|
||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||
placeholder={t("plex.playlist.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylist.trim()}
|
||||
title={t("plex.playlist.create")}
|
||||
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{playlistsQ.data!.playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onOpenPlaylist(p.id)}
|
||||
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
||||
>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||
</button>
|
||||
))}
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "playlists",
|
||||
title: t("plex.playlist.section"),
|
||||
body: (
|
||||
<>
|
||||
<div className="mb-1.5 flex gap-1.5">
|
||||
<input
|
||||
value={newPlaylist}
|
||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||
placeholder={t("plex.playlist.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylist.trim()}
|
||||
title={t("plex.playlist.create")}
|
||||
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{anyActive && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")} ({activeCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
|
||||
<Section label={t("plex.filter.show")}>
|
||||
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{playlistsQ.data!.playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onOpenPlaylist(p.id)}
|
||||
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
||||
>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "watch",
|
||||
title: t("plex.filter.show"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
{SHOW_OPTS.map((s) => (
|
||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
||||
@@ -219,32 +183,36 @@ export default function PlexSidebar({
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Sort + direction */}
|
||||
<Section label={t("plex.filter.sort")}>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</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>
|
||||
</Section>
|
||||
|
||||
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
|
||||
{(
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sort",
|
||||
title: t("plex.filter.sort"),
|
||||
body: (
|
||||
<>
|
||||
{/* Active people / studios — set by clicking the info page (stackable). */}
|
||||
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
||||
<Section label={t("plex.filter.active")}>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</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
|
||||
? [
|
||||
{
|
||||
id: "people",
|
||||
title: t("plex.filter.active"),
|
||||
body: (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.directors.map((d) => (
|
||||
<RemovableChip
|
||||
@@ -268,116 +236,128 @@ export default function PlexSidebar({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Collection — the active one shows as a chip; otherwise a searchable picker over the union
|
||||
of all enabled libraries' collections (also settable from an item info page's "Browse
|
||||
collection"). */}
|
||||
<Section label={t("plex.filter.collection")}>
|
||||
{filters.collection ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
value={collSearch}
|
||||
onChange={(e) => setCollSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
<span className="text-xs text-muted">{c.child_count}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "collection",
|
||||
title: t("plex.filter.collection"),
|
||||
body: filters.collection ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
value={collSearch}
|
||||
onChange={(e) => setCollSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
<span className="text-xs text-muted">{c.child_count}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* IMDb rating min */}
|
||||
<Section label={t("plex.filter.rating")}>
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Genres (from facets) + Any/All when more than one is picked */}
|
||||
{facets && facets.genres.length > 0 && (
|
||||
<Section label={t("plex.filter.genre")}>
|
||||
{filters.genres.length > 1 && (
|
||||
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => patch({ genreMode: m })}
|
||||
className={`px-2 py-0.5 transition ${
|
||||
(filters.genreMode ?? "any") === m
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:bg-surface"
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rating",
|
||||
title: t("plex.filter.rating"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
),
|
||||
},
|
||||
...(facets && facets.genres.length > 0
|
||||
? [
|
||||
{
|
||||
id: "genre",
|
||||
title: t("plex.filter.genre"),
|
||||
body: (
|
||||
<>
|
||||
{filters.genres.length > 1 && (
|
||||
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => patch({ genreMode: m })}
|
||||
className={`px-2 py-0.5 transition ${
|
||||
(filters.genreMode ?? "any") === m
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChipRow>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
>
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
{g.value}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChipRow>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
>
|
||||
{g.value}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Year range */}
|
||||
<Section label={t("plex.filter.year")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||
{scope === "movie" && (
|
||||
<Section label={t("plex.filter.duration")}>
|
||||
</ChipRow>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "year",
|
||||
title: t("plex.filter.year"),
|
||||
body: (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(scope === "movie"
|
||||
? [
|
||||
{
|
||||
id: "duration",
|
||||
title: t("plex.filter.duration"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip
|
||||
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
|
||||
@@ -395,26 +375,32 @@ export default function PlexSidebar({
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Added to Plex */}
|
||||
<Section label={t("plex.filter.added")}>
|
||||
<ChipRow>
|
||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{ADDED_OPTS.map((a) => (
|
||||
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
||||
{t(`plex.filter.addedOpt.${a}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Content rating (from facets) */}
|
||||
{facets && facets.content_ratings.length > 0 && (
|
||||
<Section label={t("plex.filter.contentRating")}>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "added",
|
||||
title: t("plex.filter.added"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{ADDED_OPTS.map((a) => (
|
||||
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
||||
{t(`plex.filter.addedOpt.${a}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
),
|
||||
},
|
||||
...(facets && facets.content_ratings.length > 0
|
||||
? [
|
||||
{
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
{facets.content_ratings.map((c) => (
|
||||
<Chip
|
||||
@@ -426,20 +412,37 @@ export default function PlexSidebar({
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
<SidePanel
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("plex"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={
|
||||
anyActive ? (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted hover:text-accent transition px-1 whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user