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 // @vitest-environment jsdom
// //
// E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the // E5 S7: the combi-input (chips + typed search field) multi-value filter. Covers the selected-chip
// popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is // removal, opening the list by FOCUSING the field (not a button anymore), the search filter, and
// floating-ui based (ActionMenu's test recipe: stub the two observers autoUpdate wires, wrap in a // toggling an option on. i18n is key-passthrough; the popover is floating-ui based (ActionMenu's test
// FloatingTree, and query the hidden surface with `{ hidden: true }` since jsdom never positions it). // 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 { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react"; import { FloatingTree } from "@floating-ui/react";
@@ -51,17 +52,18 @@ describe("MultiSelectChips", () => {
expect(onChange).toHaveBeenCalledWith([]); // Drama removed 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()} />); wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull(); 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); 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()} />); wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); const field = screen.getByRole("textbox");
fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } }); fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } });
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true }); const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
expect(items.length).toBe(1); expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy"); expect(items[0]?.textContent).toContain("Comedy");
@@ -72,16 +74,30 @@ describe("MultiSelectChips", () => {
wrap( wrap(
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />, <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 fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]); 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", () => { 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. // 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 })); const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />); 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.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50);
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search 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 { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { useScrollFade } from "../lib/useScrollFade";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A compact "listbox-select + removable chips" multi-value filter control (E5 S7), replacing a flat // A "combi input" multi-value filter control (E5 S7, reshaped): the selected values are removable
// wall of toggle chips: the selected values show as removable chips, and a "+" trigger opens a // chips that live INSIDE a text field you can type into (the text filters the vocabulary), and the
// searchable checklist popover (AnchoredPopover platform — menuitemcheckbox items, arrow/Escape, anchor // searchable checklist opens below on focus — a react-select / Overseerr-style combobox. It replaces
// flip). Options carry an optional count. Space-saving for a large vocabulary (genres / studios), which // the earlier "+Add button opening a popover" so a large vocabulary (genres / studios) reads as one
// is exactly why it replaces the always-expanded chip lists in the Plex filter panel. // 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 { export interface MultiSelectOption {
value: string; value: string;
@@ -23,7 +25,7 @@ export default function MultiSelectChips({
addLabel, addLabel,
searchPlaceholder, searchPlaceholder,
}: { }: {
/** Accessible name for the popover + trigger (e.g. "Genre"). */ /** Accessible name for the field + listbox (e.g. "Genre"). */
label: string; label: string;
options: readonly MultiSelectOption[]; options: readonly MultiSelectOption[];
selected: string[]; selected: string[];
@@ -36,8 +38,8 @@ export default function MultiSelectChips({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade(); const fade = useScrollFade();
// A free-text search field lives in the surface — keep typed chars + pointer-hover out of the roving // hostsInput keeps the platform's list-navigation/typeahead from stealing the field's caret; open is
// list so neither steals the field's caret (same reason ChannelTags' tag editor sets this). // controlled here so the field's focus (not a click-to-toggle) drives it.
const pop = useAnchoredPopover({ const pop = useAnchoredPopover({
open, open,
onOpenChange: setOpen, onOpenChange: setOpen,
@@ -45,10 +47,9 @@ export default function MultiSelectChips({
placement: "bottom-start", placement: "bottom-start",
hostsInput: true, 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(() => { useEffect(() => {
if (open) inputRef.current?.focus(); if (!open) setQuery("");
else setQuery("");
}, [open]); }, [open]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -57,8 +58,8 @@ export default function MultiSelectChips({
}, [options, query]); }, [options, query]);
// A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap // 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 // the rendered rows and let search narrow to the rest. Options arrive count-desc, so the default view
// view is the most-common values; a footer flags how many more are hidden behind the search. // is the most-common values; a footer flags how many more are hidden behind the search.
const MAX_VISIBLE = 50; const MAX_VISIBLE = 50;
const visible = filtered.slice(0, MAX_VISIBLE); const visible = filtered.slice(0, MAX_VISIBLE);
const hiddenCount = filtered.length - visible.length; const hiddenCount = filtered.length - visible.length;
@@ -68,56 +69,74 @@ export default function MultiSelectChips({
return ( return (
<div> <div>
{selected.length > 0 && ( {/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
<div className="mb-1.5 flex flex-wrap gap-1.5"> <div
{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} ref={pop.refs.setReference}
type="button" onMouseDown={(e) => {
aria-label={addLabel ?? t("plex.filter.add")} // A click anywhere in the box (padding, a chip gap) focuses the field and opens the list —
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" // never toggles it shut; the field's onFocus is the single source of "open".
{...pop.getReferenceProps()} 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" /> {selected.map((v) => (
{addLabel ?? t("plex.filter.add")} <span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
</button> <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 <PopoverSurface
popover={pop} popover={pop}
ariaLabel={label} ariaLabel={label}
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]" 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"> <div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted"> <p className="px-2 py-1.5 text-xs text-muted">
@@ -132,7 +151,10 @@ export default function MultiSelectChips({
key={o.value} key={o.value}
role="menuitemcheckbox" role="menuitemcheckbox"
checked={on} 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} 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 ${ 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" on ? "text-fg" : "text-muted"
+2 -2
View File
@@ -56,7 +56,7 @@ function CardRail({
title title
); );
return ( return (
<section className="mb-8"> <section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}> <Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
{cards.map((c) => ( {cards.map((c) => (
<div key={c.id} className="w-[150px] shrink-0"> <div key={c.id} className="w-[150px] shrink-0">
@@ -198,7 +198,7 @@ export default function PlexDiscover({
)} )}
{d.genres.length > 0 && ( {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> <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"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => { {d.genres.map((g) => {
+70 -15
View File
@@ -14,6 +14,7 @@ import {
useCrossfadeBackdrop, useCrossfadeBackdrop,
useDetailPrefs, useDetailPrefs,
} from "../lib/plexDetailUi"; } from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -222,7 +223,9 @@ function CrewPanel({
return ( return (
<div className="glass-card rounded-2xl p-4 sm:p-5"> <div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2> <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) => ( {crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0"> <div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? ( {onOpenPerson ? (
@@ -280,6 +283,10 @@ export default function PlexInfo({
// is PlexInfo-specific state, persisted through the same savePref. // is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } = const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs(); 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 ?? const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] }; {}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled // 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) => const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); 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 ( return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}> <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). */} {/* 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"}> <div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
{/* Header: close (overlay) / customize */} {/* Header: close (overlay) / customize */}
@@ -359,14 +381,39 @@ export default function PlexInfo({
showCast={showCast} showCast={showCast}
onToggleCast={toggleCast} onToggleCast={toggleCast}
hasCast={detail.cast.length > 0} hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => ( extra={
<PrefToggle <>
key={src} {hasFacts && (
label={sourceLabel(src)} <PrefToggle
on={!hiddenSources.includes(src)} label={t("plex.info.prefFacts")}
onClick={() => toggleSource(src)} 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 && ( {overlay && onClose && (
<button <button
@@ -548,9 +595,18 @@ export default function PlexInfo({
</div> </div>
{/* End hero panel. */} {/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. */} {/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
{!overlay && <FactsPanel detail={detail} onFilter={onFilter} />} Crew sit side by side (each its natural height, items-start) so no single box hogs the full
{!overlay && <KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />} 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 row (toggleable), circular photos. */}
{cast.length > 0 && ( {cast.length > 0 && (
@@ -617,8 +673,7 @@ export default function PlexInfo({
</div> </div>
)} )}
{/* Full crew (E5 S5) — TMDB key-crew, page variant only. */} {/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
{!overlay && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
{/* Collection strips — the other titles in this movie's collection(s), directly playable. {/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */} Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
+9 -2
View File
@@ -348,12 +348,19 @@ export function CastStrip({
</div> </div>
)} )}
</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 <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} {c.name}
</div> </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 ? ( return clickable ? (
+2 -2
View File
@@ -144,7 +144,7 @@ export function PlexPersonView({
const section = (titleKey: string, cards: PlexCard[]) => const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? ( 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> <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"> <div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => ( {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). */} {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && ( {d.known_for.length > 0 && (
<section className="mb-8"> <section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}> <Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => ( {d.known_for.map((c) => (
<div key={c.id} className="w-[150px] shrink-0"> <div key={c.id} className="w-[150px] shrink-0">
+3
View File
@@ -189,6 +189,9 @@
"prefArtBg": "Faint backdrop", "prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew", "prefCast": "Cast & crew",
"prefRelated": "Related shows", "prefRelated": "Related shows",
"prefFacts": "Details",
"prefKeywords": "Keywords",
"prefCrew": "Crew",
"stripSource": { "stripSource": {
"collection": "Collections", "collection": "Collections",
"imdb": "IMDb", "imdb": "IMDb",
+3 -1
View File
@@ -72,7 +72,8 @@
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.", "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.", "plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.", "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded." "audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS poll (new uploads)", "rss_poll": "RSS poll (new uploads)",
@@ -89,6 +90,7 @@
"plex_sync": "Plex library sync", "plex_sync": "Plex library sync",
"plex_watch_sync": "Plex watch sync (incremental)", "plex_watch_sync": "Plex watch sync (incremental)",
"plex_watch_reconcile": "Plex watch reconcile (full)", "plex_watch_reconcile": "Plex watch reconcile (full)",
"tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log cleanup" "audit_gc": "Audit-log cleanup"
}, },
"queue": { "queue": {
+3
View File
@@ -189,6 +189,9 @@
"prefArtBg": "Halvány háttér", "prefArtBg": "Halvány háttér",
"prefCast": "Szereplők", "prefCast": "Szereplők",
"prefRelated": "Kapcsolódó sorozatok", "prefRelated": "Kapcsolódó sorozatok",
"prefFacts": "Részletek",
"prefKeywords": "Kulcsszavak",
"prefCrew": "Stáb",
"stripSource": { "stripSource": {
"collection": "Kollekciók", "collection": "Kollekciók",
"imdb": "IMDb", "imdb": "IMDb",
+3 -1
View File
@@ -72,7 +72,8 @@
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.", "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.", "plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.",
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul." "audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)", "rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -89,6 +90,7 @@
"plex_sync": "Plex-könyvtár szinkron", "plex_sync": "Plex-könyvtár szinkron",
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)", "plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)", "plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)",
"tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Napló-karbantartás" "audit_gc": "Napló-karbantartás"
}, },
"queue": { "queue": {
+4 -2
View File
@@ -125,7 +125,7 @@ export function useCrossfadeBackdrop(
const pool = key ? key.split("|") : []; const pool = key ? key.split("|") : [];
if (!enabled || pool.length === 0) { if (!enabled || pool.length === 0) {
clear(); clear();
return; return clear; // and clear again on unmount / dep-change (idempotent)
} }
paint(pool[0]!); paint(pool[0]!);
@@ -133,7 +133,9 @@ export function useCrossfadeBackdrop(
typeof window !== "undefined" && typeof window !== "undefined" &&
typeof window.matchMedia === "function" && typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches; window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!animate || reduce || pool.length < 2) return; // static: first image only // Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
if (!animate || reduce || pool.length < 2) return clear;
const layer = document.createElement("div"); const layer = document.createElement("div");
Object.assign(layer.style, { Object.assign(layer.style, {