feat(plex): field-scoped search (S2) + instant search typeahead

Add a search scope-pill to the Plex header search box (GitHub-style, one
field-scope at a time) and an instant typeahead of the top matches below it.

Backend (unified_library):
- New `field` param: all | title | people | summary | episode. `all` is
  byte-identical to the pre-scope behaviour (untouched weighted search_vector);
  a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a
  per-column tsvector recheck, so scoped hits are a strict subset of Everywhere.
  Per-field expressions mirror the search_vector Computed def (same TS config,
  same left(summary,1000) truncation).
- `episode` scope empties the movie/show grid and title-only-matches the grouped
  Episodes section; the title/people/summary scopes suppress that section.
- Unknown field falls back to `all`. New DB-lane characterization tests
  (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is
  identical to today, episode empties the grid and needs shows in scope.

Frontend:
- PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing
  ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/
  keydown slots so other modules' search boxes are untouched.
- Episode scope offered only when shows are in scope (movie-only hides it),
  healed at the point of use like the sort state.
- Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the
  sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc),
  and jumps straight to a movie's info page or a show's drill-down via a new
  provider jumpTo hand-off (mirrors the playlistOpen pattern).
- `field` persisted per-account; en/hu labels added.
This commit is contained in:
2026-08-16 08:05:18 +02:00
parent 73a58674cf
commit f9681deecf
13 changed files with 555 additions and 114 deletions
+57 -35
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { type FocusEventHandler, type KeyboardEventHandler, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
@@ -6,6 +6,12 @@ import { Search, X } from "lucide-react";
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
//
// Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the
// search icon with a control (Plex's search scope-pill), and `below` renders an absolutely
// positioned panel attached under the box (Plex's instant typeahead results). `onFocus`/`onBlur`/
// `onKeyDown` pass through to the input so a caller can drive that panel (open on focus, arrow-key
// navigation); the built-in Enter→Go fires only if the caller didn't `preventDefault()` first.
export default function SearchBar({
value,
onChange,
@@ -15,6 +21,11 @@ export default function SearchBar({
goIcon,
goDisabled,
goTitle,
leading,
below,
onFocus,
onBlur,
onKeyDown,
}: {
value: string;
onChange: (q: string) => void;
@@ -25,46 +36,57 @@ export default function SearchBar({
goIcon?: ReactNode;
goDisabled?: boolean;
goTitle?: string;
leading?: ReactNode;
below?: ReactNode;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
}) {
const { t } = useTranslation();
const fire = () => {
if (onGo && !goDisabled) onGo();
};
return (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fire();
}}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
/>
{value && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
)}
{onGo && (
<button
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl">
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key === "Enter" && !e.defaultPrevented) fire();
}}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
/>
{value && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
)}
{onGo && (
<button
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
</div>
{below}
</div>
);
}