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:
@@ -312,6 +312,7 @@ def facets(
|
||||
empty = {
|
||||
"genres": [],
|
||||
"content_ratings": [],
|
||||
"studios": [],
|
||||
"year_min": None,
|
||||
"year_max": None,
|
||||
"rating_max": None,
|
||||
@@ -355,6 +356,7 @@ def facets(
|
||||
|
||||
genre_counts: dict[str, int] = {}
|
||||
cr_counts: dict[str, int] = {}
|
||||
studio_counts: dict[str, int] = {}
|
||||
years: list[int] = []
|
||||
ratings: list[float] = []
|
||||
durs: list[int] = []
|
||||
@@ -406,6 +408,21 @@ def facets(
|
||||
)
|
||||
for cr, c in db.execute(crq).all():
|
||||
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
||||
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
|
||||
stq = (
|
||||
wsq(
|
||||
select(model.studio, func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "studios": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(model.studio.isnot(None))
|
||||
.group_by(model.studio)
|
||||
)
|
||||
for s, c in db.execute(stq).all():
|
||||
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||
# Year bounds — exclude the year filter.
|
||||
by = db.execute(
|
||||
wsq(
|
||||
@@ -460,6 +477,9 @@ def facets(
|
||||
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
|
||||
for g in split_csv(f.genres):
|
||||
genre_counts.setdefault(g, 0)
|
||||
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||
for s in split_csv(f.studios):
|
||||
studio_counts.setdefault(s, 0)
|
||||
return {
|
||||
"genres": [
|
||||
{"value": g, "count": c}
|
||||
@@ -469,6 +489,10 @@ def facets(
|
||||
{"value": cr, "count": c}
|
||||
for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
|
||||
],
|
||||
"studios": [
|
||||
{"value": s, "count": c}
|
||||
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
],
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"rating_max": max(ratings) if ratings else None,
|
||||
|
||||
@@ -12,7 +12,33 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexLibrary, PlexShow, User
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
||||
|
||||
|
||||
def test_facets_returns_studio_vocabulary(db, monkeypatch):
|
||||
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"),
|
||||
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"),
|
||||
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"),
|
||||
PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio
|
||||
])
|
||||
user = User(email="studiofacets@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
|
||||
assert studios == {"Pixar": 2, "A24": 1}
|
||||
|
||||
|
||||
def test_facets_survives_json_null_genres(db, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the
|
||||
// popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is
|
||||
// floating-ui based (ActionMenu's test recipe: stub the two observers autoUpdate wires, wrap in a
|
||||
// FloatingTree, and query the hidden surface with `{ hidden: true }` since jsdom never positions it).
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import MultiSelectChips from "./MultiSelectChips";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "Drama", count: 120 },
|
||||
{ value: "Action", count: 90 },
|
||||
{ value: "Comedy", count: 60 },
|
||||
];
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(<FloatingTree>{ui}</FloatingTree>);
|
||||
}
|
||||
|
||||
describe("MultiSelectChips", () => {
|
||||
it("renders selected values as removable chips and removes on ✕", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
|
||||
);
|
||||
expect(screen.getByText("Drama")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
|
||||
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
|
||||
});
|
||||
|
||||
it("keeps the option list unmounted until the trigger is clicked", () => {
|
||||
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
||||
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3);
|
||||
});
|
||||
|
||||
it("filters the options by the search query", () => {
|
||||
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } });
|
||||
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0]?.textContent).toContain("Comedy");
|
||||
});
|
||||
|
||||
it("toggles an option on with its value added to the selection", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
||||
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
|
||||
});
|
||||
|
||||
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
|
||||
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
|
||||
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
||||
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50);
|
||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Plus, Search, X } from "lucide-react";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A compact "listbox-select + removable chips" multi-value filter control (E5 S7), replacing a flat
|
||||
// wall of toggle chips: the selected values show as removable chips, and a "+" trigger opens a
|
||||
// searchable checklist popover (AnchoredPopover platform — menuitemcheckbox items, arrow/Escape, anchor
|
||||
// flip). Options carry an optional count. Space-saving for a large vocabulary (genres / studios), which
|
||||
// is exactly why it replaces the always-expanded chip lists in the Plex filter panel.
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export default function MultiSelectChips({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
addLabel,
|
||||
searchPlaceholder,
|
||||
}: {
|
||||
/** Accessible name for the popover + trigger (e.g. "Genre"). */
|
||||
label: string;
|
||||
options: readonly MultiSelectOption[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
addLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fade = useScrollFade();
|
||||
// A free-text search field lives in the surface — keep typed chars + pointer-hover out of the roving
|
||||
// list so neither steals the field's caret (same reason ChannelTags' tag editor sets this).
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
hostsInput: true,
|
||||
});
|
||||
// Focus the search field when the menu opens; clear the query when it closes.
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
else setQuery("");
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? options.filter((o) => o.value.toLowerCase().includes(q)) : options;
|
||||
}, [options, query]);
|
||||
|
||||
// A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap
|
||||
// the rendered rows and let search narrow to the rest. The options arrive count-desc, so the default
|
||||
// view is the most-common values; a footer flags how many more are hidden behind the search.
|
||||
const MAX_VISIBLE = 50;
|
||||
const visible = filtered.slice(0, MAX_VISIBLE);
|
||||
const hiddenCount = filtered.length - visible.length;
|
||||
|
||||
const toggle = (v: string) =>
|
||||
onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selected.length > 0 && (
|
||||
<div className="mb-1.5 flex flex-wrap gap-1.5">
|
||||
{selected.map((v) => (
|
||||
<span key={v} className="chip px-2 py-1 text-xs">
|
||||
<span className="max-w-36 truncate">{v}</span>
|
||||
<button
|
||||
onClick={() => toggle(v)}
|
||||
aria-label={t("common.remove", { defaultValue: "Remove" })}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={addLabel ?? 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" />
|
||||
{addLabel ?? t("plex.filter.add")}
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
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={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Escape bubbles to the popover's dismiss (close + refocus trigger); every other key —
|
||||
// typed chars, arrows, Home/End — is text editing here, kept out of the surface's
|
||||
// list-navigation/typeahead handlers so the caret behaves.
|
||||
if (e.key === "Escape") return;
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder={searchPlaceholder ?? t("common.search", { defaultValue: "Search" })}
|
||||
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">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{t("common.noResults", { defaultValue: "No matches" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{visible.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="menuitemcheckbox"
|
||||
checked={on}
|
||||
onSelect={() => toggle(o.value)}
|
||||
label={o.value}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-hidden transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{o.value}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
|
||||
)}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||||
{t("plex.filter.moreRefine", {
|
||||
count: hiddenCount,
|
||||
defaultValue: "{{count}} more — refine with search",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -22,5 +22,8 @@
|
||||
"loadError": "Couldn't load this — check your connection and try again.",
|
||||
"seeAll": "See all",
|
||||
"scrollLeft": "Scroll left",
|
||||
"scrollRight": "Scroll right"
|
||||
"scrollRight": "Scroll right",
|
||||
"search": "Search",
|
||||
"remove": "Remove",
|
||||
"noResults": "No matches"
|
||||
}
|
||||
|
||||
@@ -70,7 +70,10 @@
|
||||
"collection": "Collection",
|
||||
"collectionSearch": "Search collections…",
|
||||
"collectionEmpty": "No collections",
|
||||
"matchLabel": "Match"
|
||||
"matchLabel": "Match",
|
||||
"studio": "Studio",
|
||||
"add": "Add",
|
||||
"moreRefine": "{{count}} more — refine with search"
|
||||
},
|
||||
"count": "{{count}} titles",
|
||||
"searchCount": "{{count}} matches",
|
||||
|
||||
@@ -22,5 +22,8 @@
|
||||
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.",
|
||||
"seeAll": "Összes",
|
||||
"scrollLeft": "Görgetés balra",
|
||||
"scrollRight": "Görgetés jobbra"
|
||||
"scrollRight": "Görgetés jobbra",
|
||||
"search": "Keresés",
|
||||
"remove": "Eltávolítás",
|
||||
"noResults": "Nincs találat"
|
||||
}
|
||||
|
||||
@@ -70,7 +70,10 @@
|
||||
"collection": "Kollekció",
|
||||
"collectionSearch": "Kollekciók keresése…",
|
||||
"collectionEmpty": "Nincs kollekció",
|
||||
"matchLabel": "Egyezés"
|
||||
"matchLabel": "Egyezés",
|
||||
"studio": "Stúdió",
|
||||
"add": "Hozzáadás",
|
||||
"moreRefine": "még {{count}} — finomíts kereséssel"
|
||||
},
|
||||
"count": "{{count}} cím",
|
||||
"searchCount": "{{count}} találat",
|
||||
|
||||
@@ -288,6 +288,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||
export interface PlexFacets {
|
||||
genres: { value: string; count: number }[];
|
||||
content_ratings: { value: string; count: number }[];
|
||||
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
|
||||
year_min: number | null;
|
||||
year_max: number | null;
|
||||
rating_max: number | null;
|
||||
|
||||
Reference in New Issue
Block a user