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:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -189,6 +189,9 @@
|
||||
"prefArtBg": "Faint backdrop",
|
||||
"prefCast": "Cast & crew",
|
||||
"prefRelated": "Related shows",
|
||||
"prefFacts": "Details",
|
||||
"prefKeywords": "Keywords",
|
||||
"prefCrew": "Crew",
|
||||
"stripSource": {
|
||||
"collection": "Collections",
|
||||
"imdb": "IMDb",
|
||||
|
||||
@@ -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_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.",
|
||||
"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": {
|
||||
"rss_poll": "RSS poll (new uploads)",
|
||||
@@ -89,6 +90,7 @@
|
||||
"plex_sync": "Plex library sync",
|
||||
"plex_watch_sync": "Plex watch sync (incremental)",
|
||||
"plex_watch_reconcile": "Plex watch reconcile (full)",
|
||||
"tmdb_enrich": "TMDB enrichment (Plex)",
|
||||
"audit_gc": "Audit-log cleanup"
|
||||
},
|
||||
"queue": {
|
||||
|
||||
@@ -189,6 +189,9 @@
|
||||
"prefArtBg": "Halvány háttér",
|
||||
"prefCast": "Szereplők",
|
||||
"prefRelated": "Kapcsolódó sorozatok",
|
||||
"prefFacts": "Részletek",
|
||||
"prefKeywords": "Kulcsszavak",
|
||||
"prefCrew": "Stáb",
|
||||
"stripSource": {
|
||||
"collection": "Kollekciók",
|
||||
"imdb": "IMDb",
|
||||
|
||||
@@ -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_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 újraszkennel — 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": {
|
||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||
@@ -89,6 +90,7 @@
|
||||
"plex_sync": "Plex-könyvtár szinkron",
|
||||
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
|
||||
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)",
|
||||
"tmdb_enrich": "TMDB-dúsítás (Plex)",
|
||||
"audit_gc": "Napló-karbantartás"
|
||||
},
|
||||
"queue": {
|
||||
|
||||
@@ -125,7 +125,7 @@ export function useCrossfadeBackdrop(
|
||||
const pool = key ? key.split("|") : [];
|
||||
if (!enabled || pool.length === 0) {
|
||||
clear();
|
||||
return;
|
||||
return clear; // and clear again on unmount / dep-change (idempotent)
|
||||
}
|
||||
paint(pool[0]!);
|
||||
|
||||
@@ -133,7 +133,9 @@ export function useCrossfadeBackdrop(
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
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");
|
||||
Object.assign(layer.style, {
|
||||
|
||||
Reference in New Issue
Block a user