fix(e5): Plex detail/UI polish from review (backdrop reset, layout, combobox, labels)

User-reported issues after the E5 build:

- useCrossfadeBackdrop: the static branch (one image / crossfade off /
  reduced motion) painted #root but returned NO cleanup, so the backdrop
  persisted after leaving the page (e.g. switching modules). Every painted
  branch now returns clear() so unmount always resets the default backdrop.
- scheduler: give the tmdb_enrich job a proper label + description (was the
  raw "tmdb_enrich" id) in EN/HU.
- CastStrip: cap crew names to ONE line (truncate) so the row of circular
  photos sits on one baseline instead of going wavy on 2-line names.
- PlexInfo: lay Details / Keywords / Crew out as side-by-side boxes
  (items-start grid) instead of full-width stacked panels with dead space;
  narrower Crew grid for the boxed width; each panel independently hideable
  from the customize menu (new Details/Keywords/Crew toggles). Tighter
  vertical spacing.
- MultiSelectChips: reshape the trigger from a "+Add" button into an
  Overseerr-style combi input — removable chips inside a typeable field that
  filters the checklist, opening on focus (backspace removes the last chip).
- Discover / person rails: reduce the vertical gap between rails.
This commit is contained in:
2026-08-21 03:16:40 +02:00
parent 2b5de89eda
commit b83248c510
11 changed files with 205 additions and 93 deletions
@@ -1,9 +1,10 @@
// @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).
// E5 S7: the combi-input (chips + typed search field) multi-value filter. Covers the selected-chip
// removal, opening the list by FOCUSING the field (not a button anymore), 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";
@@ -51,17 +52,18 @@ describe("MultiSelectChips", () => {
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
it("keeps the option list unmounted until the trigger is clicked", () => {
it("keeps the option list unmounted until the field is focused", () => {
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" }));
fireEvent.focus(screen.getByRole("textbox"));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3);
});
it("filters the options by the search query", () => {
it("filters the options by the typed 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 field = screen.getByRole("textbox");
fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } });
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy");
@@ -72,16 +74,30 @@ describe("MultiSelectChips", () => {
wrap(
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.focus(screen.getByRole("textbox"));
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
});
it("removes the last chip on Backspace in an empty field", () => {
const onChange = vi.fn();
wrap(
<MultiSelectChips
label="Genre"
options={OPTIONS}
selected={["Drama", "Action"]}
onChange={onChange}
/>,
);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Backspace" });
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
});
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" }));
fireEvent.focus(screen.getByRole("textbox"));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50);
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
});
+79 -57
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Plus, Search, X } from "lucide-react";
import { Check, ChevronDown, 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.
// A "combi input" multi-value filter control (E5 S7, reshaped): the selected values are removable
// chips that live INSIDE a text field you can type into (the text filters the vocabulary), and the
// searchable checklist opens below on focus — a react-select / Overseerr-style combobox. It replaces
// the earlier "+Add button opening a popover" so a large vocabulary (genres / studios) reads as one
// familiar input rather than a wall of toggle chips. Positioning + outside-dismiss come from the
// AnchoredPopover platform (refs only — open is driven by the field's focus so a click to place the
// caret never toggles the list shut); the field's own keydown owns Escape + backspace-to-remove.
export interface MultiSelectOption {
value: string;
@@ -23,7 +25,7 @@ export default function MultiSelectChips({
addLabel,
searchPlaceholder,
}: {
/** Accessible name for the popover + trigger (e.g. "Genre"). */
/** Accessible name for the field + listbox (e.g. "Genre"). */
label: string;
options: readonly MultiSelectOption[];
selected: string[];
@@ -36,8 +38,8 @@ export default function MultiSelectChips({
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).
// hostsInput keeps the platform's list-navigation/typeahead from stealing the field's caret; open is
// controlled here so the field's focus (not a click-to-toggle) drives it.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
@@ -45,10 +47,9 @@ export default function MultiSelectChips({
placement: "bottom-start",
hostsInput: true,
});
// Focus the search field when the menu opens; clear the query when it closes.
// Clear the typed query when the list closes.
useEffect(() => {
if (open) inputRef.current?.focus();
else setQuery("");
if (!open) setQuery("");
}, [open]);
const filtered = useMemo(() => {
@@ -57,8 +58,8 @@ export default function MultiSelectChips({
}, [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.
// the rendered rows and let search narrow to the rest. 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;
@@ -68,56 +69,74 @@ export default function MultiSelectChips({
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
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
<div
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()}
onMouseDown={(e) => {
// A click anywhere in the box (padding, a chip gap) focuses the field and opens the list —
// never toggles it shut; the field's onFocus is the single source of "open".
if (e.target !== inputRef.current) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
}}
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
>
<Plus className="h-3.5 w-3.5" />
{addLabel ?? t("plex.filter.add")}
</button>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
<span className="max-w-36 truncate">{v}</span>
<button
type="button"
onMouseDown={(e) => {
// Keep the box's focus-the-field handler from firing when removing a chip.
e.stopPropagation();
e.preventDefault();
}}
onClick={() => toggle(v)}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
// Backspace on an empty field removes the last chip (react-select behaviour).
if (e.key === "Backspace" && query === "" && selected.length > 0) {
onChange(selected.slice(0, -1));
return;
}
e.stopPropagation(); // typed chars / arrows / Home / End are text editing, not list nav
}}
placeholder={
selected.length === 0
? (addLabel ?? t("plex.filter.add"))
: (searchPlaceholder ?? t("common.search", { defaultValue: "Search" }))
}
aria-label={label}
className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted"
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
<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">
@@ -132,7 +151,10 @@ export default function MultiSelectChips({
key={o.value}
role="menuitemcheckbox"
checked={on}
onSelect={() => toggle(o.value)}
onSelect={() => {
toggle(o.value);
inputRef.current?.focus(); // keep the field hot for the next pick / more typing
}}
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"
+2 -2
View File
@@ -56,7 +56,7 @@ function CardRail({
title
);
return (
<section className="mb-8">
<section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
{cards.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">
@@ -198,7 +198,7 @@ export default function PlexDiscover({
)}
{d.genres.length > 0 && (
<section className="mb-8">
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => {
+70 -15
View File
@@ -14,6 +14,7 @@ import {
useCrossfadeBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -222,7 +223,9 @@ function CrewPanel({
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3 md:grid-cols-4">
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
@@ -280,6 +283,10 @@ export default function PlexInfo({
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
@@ -333,9 +340,24 @@ export default function PlexInfo({
const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
// (no toggle for a panel that would be empty anyway).
const tmdb = detail.tmdb;
const hasFacts =
detail.year != null ||
!!detail.duration_seconds ||
!!detail.content_rating ||
!!tmdb?.budget ||
!!tmdb?.revenue ||
!!tmdb?.studios?.length ||
!!detail.studio ||
!!tmdb?.production_countries?.length;
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
{/* Header: close (overlay) / customize */}
@@ -359,14 +381,39 @@ export default function PlexInfo({
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
extra={
<>
{hasFacts && (
<PrefToggle
label={t("plex.info.prefFacts")}
on={showFacts}
onClick={() => setShowFacts(!showFacts)}
/>
)}
{hasKeywords && (
<PrefToggle
label={t("plex.info.prefKeywords")}
on={showKeywords}
onClick={() => setShowKeywords(!showKeywords)}
/>
)}
{hasCrew && (
<PrefToggle
label={t("plex.info.prefCrew")}
on={showCrew}
onClick={() => setShowCrew(!showCrew)}
/>
)}
{presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
</>
}
/>
{overlay && onClose && (
<button
@@ -548,9 +595,18 @@ export default function PlexInfo({
</div>
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. */}
{!overlay && <FactsPanel detail={detail} onFilter={onFilter} />}
{!overlay && <KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
width with dead space; each is independently hideable from the customize menu. */}
{!overlay && (showFacts || showKeywords || showCrew) && (
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
{showKeywords && (
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
)}
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
)}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
@@ -617,8 +673,7 @@ export default function PlexInfo({
</div>
)}
{/* Full crew (E5 S5) — TMDB key-crew, page variant only. */}
{!overlay && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
+9 -2
View File
@@ -348,12 +348,19 @@ export function CastStrip({
</div>
)}
</div>
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
same text height and the row of circles sits on one baseline regardless of name length. */}
<div
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
className={`mt-1.5 truncate text-xs font-medium leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
title={c.name}
>
{c.name}
</div>
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
{c.role && (
<div className="truncate text-2xs text-muted" title={c.role}>
{c.role}
</div>
)}
</>
);
return clickable ? (
+2 -2
View File
@@ -144,7 +144,7 @@ export function PlexPersonView({
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-8">
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
@@ -237,7 +237,7 @@ export function PlexPersonView({
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && (
<section className="mb-8">
<section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">