E5 S7: Plex filter UI — listbox-select pickers + studio filter
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.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
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, SlidersHorizontal, X } from "lucide-react";
|
||||
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";
|
||||
@@ -12,7 +13,8 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import MultiSelectChips from "./MultiSelectChips";
|
||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
@@ -68,7 +70,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||
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("");
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
@@ -93,15 +94,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
const [collSearch, setCollSearch] = useState("");
|
||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||
const collectionsQ = useQuery({
|
||||
queryKey: qk.plexCollections("union", collSearchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
|
||||
enabled: !filters.collection,
|
||||
});
|
||||
const collections = collectionsQ.data?.collections ?? [];
|
||||
|
||||
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).
|
||||
@@ -109,15 +101,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
const toggleIn = (arr: string[], v: string) =>
|
||||
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
||||
const clearAll = usePlexClearFilters();
|
||||
|
||||
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;
|
||||
// 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
|
||||
@@ -206,13 +197,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
|
||||
/>
|
||||
))}
|
||||
{filters.studios.map((s) => (
|
||||
<RemovableChip
|
||||
key={`s:${s}`}
|
||||
label={s}
|
||||
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -229,36 +213,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
/>
|
||||
</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-surface px-2 py-1 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<div
|
||||
ref={collFade.ref}
|
||||
style={collFade.style}
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{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>
|
||||
<CollectionPicker
|
||||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -284,27 +241,19 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
body: (
|
||||
<>
|
||||
{filters.genres.length > 1 && (
|
||||
<MatchToggle
|
||||
mode={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
<div className="mb-1.5">
|
||||
<MatchToggle
|
||||
mode={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("plex.filter.genre")}
|
||||
>
|
||||
{facets.genres.map((g) => (
|
||||
<button
|
||||
key={g.value}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
aria-pressed={filters.genres.includes(g.value)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{g.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MultiSelectChips
|
||||
label={t("plex.filter.genre")}
|
||||
options={facets.genres}
|
||||
selected={filters.genres}
|
||||
onChange={(next) => patch({ genres: next })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -380,24 +329,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("plex.filter.contentRating")}
|
||||
>
|
||||
{facets.content_ratings.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() =>
|
||||
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
|
||||
}
|
||||
aria-pressed={filters.contentRatings.includes(c.value)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{c.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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 })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -430,6 +383,106 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
);
|
||||
}
|
||||
|
||||
// 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">
|
||||
|
||||
Reference in New Issue
Block a user