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:
2026-08-21 00:40:31 +02:00
parent 187b8007f8
commit d56a791c04
10 changed files with 470 additions and 97 deletions
+24
View File
@@ -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,
+27 -1
View File
@@ -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>
);
}
+145 -92
View File
@@ -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">
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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",
+1
View File
@@ -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;