Reworks the always-expanded filter chip walls into compact, Seerr-style "+ Add" → searchable-popover pickers (the user's space-saving ask), and adds the missing studio filter. - New MultiSelectChips: selected values as removable chips + a "+ Add" trigger opening a searchable checklist (AnchoredPopover platform — menuitemcheckbox, arrow/Escape, anchor flip). Options carry facet counts; a large vocabulary renders a 50-row cap + "N more — refine with search" footer (studios run to ~1500). Replaces the flat Genre + Age-rating chip lists. - New Studio filter via the same picker. Backend: /facets now aggregates the studio vocabulary (distinct studio + count, self-excluded like genres). The studio filter itself already existed (only addable from the detail page before). - Collection converted to the same "+ Add" pattern (CollectionPicker): a single-select popover with server-side search + the 50-cap; the selected collection still shows as a removable chip. Retires the always-inline search+list. New i18n plex.filter.studio/add/moreRefine + common.search/remove/noResults (en+hu). Tests: MultiSelectChips.dom.test.tsx (5, incl. the cap) + test_plex_facets studio-vocabulary. Browser-E2E'd: Genre→Horror(402), Studio→A24(14, capped/searched), Collection→James Bond(26)/harry-search. Gate: vitest 342, backend lane 403.
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { qk } from "../lib/queryKeys";
|
||
import { useTranslation } from "react-i18next";
|
||
import { plexLang } from "../i18n";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Layers, ListMusic, Plus, Search, SlidersHorizontal, X } from "lucide-react";
|
||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||
import { type PanelLayout } from "../lib/panelLayout";
|
||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||
import { useDebounced } from "../lib/useDebounced";
|
||
import { useScrollFade } from "../lib/useScrollFade";
|
||
import FilterRail from "./FilterRail";
|
||
import { type PanelItem } from "./PanelGroups";
|
||
import SegmentedToggle from "./SegmentedToggle";
|
||
import FilterChipGroup from "./FilterChipGroup";
|
||
import MultiSelectChips from "./MultiSelectChips";
|
||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||
|
||
// 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 = {
|
||
layout: PanelLayout;
|
||
setLayout: (l: PanelLayout) => void;
|
||
collapsed: boolean;
|
||
onToggleCollapse: () => void;
|
||
};
|
||
|
||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] 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 }[] = [
|
||
{ key: "short", min: null, max: 90 * 60 },
|
||
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
||
{ key: "long", min: 120 * 60, max: null },
|
||
];
|
||
|
||
// The any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
|
||
// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
|
||
function MatchToggle({
|
||
mode,
|
||
onChange,
|
||
}: {
|
||
mode: "any" | "all";
|
||
onChange: (m: "any" | "all") => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<SegmentedToggle
|
||
label={t("plex.filter.matchLabel")}
|
||
ariaLabel={t("plex.filter.matchLabel")}
|
||
options={[
|
||
{ value: "any", label: t("plex.filter.match.any") },
|
||
{ value: "all", label: t("plex.filter.match.all") },
|
||
]}
|
||
value={mode}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
|
||
const { t } = useTranslation();
|
||
// 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, filters } = usePlex();
|
||
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||
const [newPlaylist, setNewPlaylist] = useState("");
|
||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||
const source: MediaSourceKind = "plex";
|
||
const qc = useQueryClient();
|
||
async function createPlaylist() {
|
||
const title = newPlaylist.trim();
|
||
if (!title) return;
|
||
const pl = await api.plexCreatePlaylist(title);
|
||
setNewPlaylist("");
|
||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||
onOpenPlaylist(pl.id);
|
||
}
|
||
const { collectionTitle: _ct, ...facetKey } = filters;
|
||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||
const facetsQ = useQuery({
|
||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||
queryFn: () => api.plexFacets(scope, filters, show, lang),
|
||
enabled: !!scope,
|
||
placeholderData: (prev) => prev,
|
||
});
|
||
const facets = facetsQ.data;
|
||
|
||
const fCount = plexFilterCount(filters);
|
||
// 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<PlexFilters>) => setFilters({ ...filters, ...p });
|
||
const clearAll = usePlexClearFilters();
|
||
|
||
const durBucketKey = DURATION_BUCKETS.find(
|
||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||
)?.key;
|
||
// Directors + actors have no picker (they're added from the detail/cast surfaces) — this section
|
||
// just lists them as removable chips. Studios now have their own picker section (below).
|
||
const peopleActive = filters.directors.length + filters.actors.length > 0;
|
||
|
||
// 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: "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 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||
/>
|
||
<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-sm 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: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.show")}
|
||
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||
value={show}
|
||
onChange={setShow}
|
||
/>
|
||
),
|
||
},
|
||
...(peopleActive
|
||
? [
|
||
{
|
||
id: "people",
|
||
title: t("plex.filter.active"),
|
||
body: (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{filters.directors.map((d) => (
|
||
<RemovableChip
|
||
key={`d:${d}`}
|
||
label={d}
|
||
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
|
||
/>
|
||
))}
|
||
{filters.actors.length > 1 && (
|
||
<div className="w-full">
|
||
<MatchToggle
|
||
mode={filters.actorMode ?? "any"}
|
||
onChange={(m) => patch({ actorMode: m })}
|
||
/>
|
||
</div>
|
||
)}
|
||
{filters.actors.map((a) => (
|
||
<RemovableChip
|
||
key={`a:${a}`}
|
||
label={a}
|
||
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
|
||
/>
|
||
))}
|
||
</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>
|
||
) : (
|
||
<CollectionPicker
|
||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: "rating",
|
||
title: t("plex.filter.rating"),
|
||
body: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.rating")}
|
||
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
|
||
options={[
|
||
{ value: "", label: t("plex.filter.any") },
|
||
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
|
||
]}
|
||
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
|
||
/>
|
||
),
|
||
},
|
||
...(facets && facets.genres.length > 0
|
||
? [
|
||
{
|
||
id: "genre",
|
||
title: t("plex.filter.genre"),
|
||
body: (
|
||
<>
|
||
{filters.genres.length > 1 && (
|
||
<div className="mb-1.5">
|
||
<MatchToggle
|
||
mode={filters.genreMode ?? "any"}
|
||
onChange={(m) => patch({ genreMode: m })}
|
||
/>
|
||
</div>
|
||
)}
|
||
<MultiSelectChips
|
||
label={t("plex.filter.genre")}
|
||
options={facets.genres}
|
||
selected={filters.genres}
|
||
onChange={(next) => patch({ genres: next })}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
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: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.duration")}
|
||
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
|
||
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
|
||
value={
|
||
durBucketKey ??
|
||
(filters.durationMin == null && filters.durationMax == null ? "" : " ")
|
||
}
|
||
options={[
|
||
{ value: "", label: t("plex.filter.any") },
|
||
...DURATION_BUCKETS.map((b) => ({
|
||
value: b.key,
|
||
label: t(`plex.filter.durationOpt.${b.key}`),
|
||
})),
|
||
]}
|
||
onChange={(key) => {
|
||
const b = DURATION_BUCKETS.find((x) => x.key === key);
|
||
patch({ durationMin: b?.min ?? null, durationMax: b?.max ?? null });
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
id: "added",
|
||
title: t("plex.filter.added"),
|
||
body: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.added")}
|
||
value={filters.addedWithin || ""}
|
||
options={[
|
||
{ value: "", label: t("plex.filter.any") },
|
||
...ADDED_OPTS.map((a) => ({ value: a, label: t(`plex.filter.addedOpt.${a}`) })),
|
||
]}
|
||
onChange={(v) => patch({ addedWithin: v })}
|
||
/>
|
||
),
|
||
},
|
||
...(facets && facets.content_ratings.length > 0
|
||
? [
|
||
{
|
||
id: "contentrating",
|
||
title: t("plex.filter.contentRating"),
|
||
body: (
|
||
<MultiSelectChips
|
||
label={t("plex.filter.contentRating")}
|
||
options={facets.content_ratings}
|
||
selected={filters.contentRatings}
|
||
onChange={(next) => patch({ contentRatings: next })}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
...(facets && facets.studios.length > 0
|
||
? [
|
||
{
|
||
id: "studio",
|
||
title: t("plex.filter.studio"),
|
||
body: (
|
||
<MultiSelectChips
|
||
label={t("plex.filter.studio")}
|
||
options={facets.studios}
|
||
selected={filters.studios}
|
||
onChange={(next) => patch({ studios: next })}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
return (
|
||
<FilterRail
|
||
kind={source}
|
||
title={t("sidebar.filters")}
|
||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||
count={activeCount}
|
||
collapsed={collapsed}
|
||
onToggleCollapse={onToggleCollapse}
|
||
layout={layout}
|
||
setLayout={setLayout}
|
||
items={items}
|
||
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
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// The collection filter as a compact "+ Add" → searchable popover (E5 S7), matching the genre/studio
|
||
// pickers instead of an always-expanded inline search + list. Single-select: picking a collection sets
|
||
// the filter and closes; the selected one then shows as a removable chip (handled by the caller).
|
||
function CollectionPicker({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||
const { t } = useTranslation();
|
||
const [open, setOpen] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const searchDeb = useDebounced(search.trim(), 300);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const fade = useScrollFade();
|
||
const q = useQuery({
|
||
queryKey: qk.plexCollections("union", searchDeb),
|
||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||
enabled: open, // only fetch while the picker is open
|
||
});
|
||
const collections = q.data?.collections ?? [];
|
||
// Search narrows server-side; cap the client render so a large union (hundreds) stays light.
|
||
const visibleCollections = collections.slice(0, 50);
|
||
const hiddenCollections = collections.length - visibleCollections.length;
|
||
const pop = useAnchoredPopover({
|
||
open,
|
||
onOpenChange: setOpen,
|
||
mode: "menu",
|
||
placement: "bottom-start",
|
||
hostsInput: true,
|
||
});
|
||
useEffect(() => {
|
||
if (open) inputRef.current?.focus();
|
||
else setSearch("");
|
||
}, [open]);
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={pop.refs.setReference}
|
||
type="button"
|
||
aria-label={t("plex.filter.add")}
|
||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted transition hover:text-accent"
|
||
{...pop.getReferenceProps()}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
{t("plex.filter.add")}
|
||
</button>
|
||
<PopoverSurface
|
||
popover={pop}
|
||
ariaLabel={t("plex.filter.collection")}
|
||
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||
>
|
||
<div className="relative mb-1">
|
||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
|
||
<input
|
||
ref={inputRef}
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") return; // let the popover dismiss handle it
|
||
e.stopPropagation(); // keep text editing out of the surface's list-nav/typeahead
|
||
}}
|
||
placeholder={t("plex.filter.collectionSearch")}
|
||
className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||
/>
|
||
</div>
|
||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||
{collections.length === 0 ? (
|
||
<p className="px-2 py-1.5 text-xs text-muted">{t("plex.filter.collectionEmpty")}</p>
|
||
) : (
|
||
<>
|
||
{visibleCollections.map((c) => (
|
||
<PopoverItem
|
||
key={c.id}
|
||
role="option"
|
||
onSelect={() => {
|
||
onPick(c.id, c.title);
|
||
setOpen(false);
|
||
}}
|
||
label={c.title}
|
||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted outline-hidden transition"
|
||
>
|
||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="flex-1 truncate">{c.title}</span>
|
||
{c.child_count != null && (
|
||
<span className="shrink-0 text-2xs tabular-nums text-muted">
|
||
{c.child_count}
|
||
</span>
|
||
)}
|
||
</PopoverItem>
|
||
))}
|
||
{hiddenCollections > 0 && (
|
||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||
{t("plex.filter.moreRefine", { count: hiddenCollections })}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</PopoverSurface>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||
return (
|
||
<span className="chip px-2 py-1 text-xs">
|
||
<span className="truncate max-w-36">{label}</span>
|
||
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function NumberInput({
|
||
value,
|
||
placeholder,
|
||
onChange,
|
||
}: {
|
||
value: number | null;
|
||
placeholder?: number;
|
||
onChange: (v: number | null) => void;
|
||
}) {
|
||
return (
|
||
<input
|
||
type="number"
|
||
inputMode="numeric"
|
||
value={value ?? ""}
|
||
placeholder={placeholder != null ? String(placeholder) : ""}
|
||
onChange={(e) => {
|
||
const n = e.target.value === "" ? null : Number(e.target.value);
|
||
onChange(n != null && Number.isFinite(n) ? n : null);
|
||
}}
|
||
className="w-20 rounded-lg border border-border bg-surface px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||
/>
|
||
);
|
||
}
|