diff --git a/frontend/src/components/MultiSelectChips.dom.test.tsx b/frontend/src/components/MultiSelectChips.dom.test.tsx index 91e76244..44b9bd59 100644 --- a/frontend/src/components/MultiSelectChips.dom.test.tsx +++ b/frontend/src/components/MultiSelectChips.dom.test.tsx @@ -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(); 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(); - 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( , ); - 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( + , + ); + 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(); - 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 }); diff --git a/frontend/src/components/MultiSelectChips.tsx b/frontend/src/components/MultiSelectChips.tsx index 981c4f0a..1e17e36d 100644 --- a/frontend/src/components/MultiSelectChips.tsx +++ b/frontend/src/components/MultiSelectChips.tsx @@ -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(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 (
- {selected.length > 0 && ( -
- {selected.map((v) => ( - - {v} - - - ))} -
- )} - - + {selected.map((v) => ( + + {v} + + + ))} + { + 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" + /> + +
-
- - 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" - /> -
{filtered.length === 0 ? (

@@ -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" diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 055184e4..ad19b79b 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -56,7 +56,7 @@ function CardRail({ title ); return ( -

+
{cards.map((c) => (
@@ -198,7 +198,7 @@ export default function PlexDiscover({ )} {d.genres.length > 0 && ( -
+

{t("plex.discover.byGenre")}

{d.genres.map((g) => { diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 23f8b5d6..3788ef31 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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 (

{t("plex.info.crew")}

-
+ {/* 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. */} +
{crew.map((c, i) => (
{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("plexInfoFacts", true); + const [showKeywords, setShowKeywords] = usePref("plexInfoKeywords", true); + const [showCrew, setShowCrew] = usePref("plexInfoCrew", true); const prefs = (qc.getQueryData<{ preferences?: Record }>(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 (
-
+
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* 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) => ( - toggleSource(src)} - /> - ))} + extra={ + <> + {hasFacts && ( + setShowFacts(!showFacts)} + /> + )} + {hasKeywords && ( + setShowKeywords(!showKeywords)} + /> + )} + {hasCrew && ( + setShowCrew(!showCrew)} + /> + )} + {presentSources.map((src) => ( + toggleSource(src)} + /> + ))} + + } /> {overlay && onClose && (
)} - {/* Full crew (E5 S5) — TMDB key-crew, page variant only. */} - {!overlay && } + {/* (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. */} diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 2829a620..5fe5438e 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -348,12 +348,19 @@ export function CastStrip({
)}
+ {/* 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. */}
{c.name}
- {c.role &&
{c.role}
} + {c.role && ( +
+ {c.role} +
+ )} ); return clickable ? ( diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index d553faff..7f9b0d1d 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -144,7 +144,7 @@ export function PlexPersonView({ const section = (titleKey: string, cards: PlexCard[]) => cards.length > 0 ? ( -
+

{t(titleKey, { count: cards.length })}

{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 && ( -
+
{d.known_for.map((c) => (
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 7c50d797..0f46cba2 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -189,6 +189,9 @@ "prefArtBg": "Faint backdrop", "prefCast": "Cast & crew", "prefRelated": "Related shows", + "prefFacts": "Details", + "prefKeywords": "Keywords", + "prefCrew": "Crew", "stripSource": { "collection": "Collections", "imdb": "IMDb", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index abaa7793..79b58399 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -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": { diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 56dfd386..110a5066 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 4ab4d883..e2b2b955 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -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 ú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": { "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": { diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 4582d47b..c1047225 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -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, {