Ships the E5 sprint stack (S1–S7): field-scoped Plex search + typeahead, cast & crew pages, the HU-overlay Hungarian metadata + language-aware serving, the IMDb/audience rating split, the toolbar relocation, and the narrow/mobile header fix — plus the underlying Plex-module refactors (feedquery/plex.cards/plex.query extraction, db.query→2.0 select). - VERSION 0.65.2 → 0.66.0 - releaseNotes.ts: 0.66.0 user-facing entry - prettier-format the E5-touched frontend files (release hygiene) Merged main (0.65.2 download-gc hotfix) in first; no code conflicts (E5 is Plex-only, the hotfix was notifications/downloads).
177 lines
7.3 KiB
TypeScript
177 lines
7.3 KiB
TypeScript
import { useEffect, useId, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ChevronDown, Film, Tv } from "lucide-react";
|
|
import SearchBar from "./SearchBar";
|
|
import ActionMenu from "./ActionMenu";
|
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
|
import { api } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
|
|
|
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
|
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
|
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
|
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
|
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
|
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
|
const MIN_CHARS = 2;
|
|
const SUGGEST_LIMIT = 10;
|
|
|
|
export default function PlexSearchBox() {
|
|
const { t } = useTranslation();
|
|
const { q, scope, field } = usePlex();
|
|
const { setQ, setField, setJumpTo } = usePlexActions();
|
|
const activeField = healPlexField(field, scope);
|
|
const dq = useDebounced(q.trim(), 200);
|
|
// `focused` mirrors the input's real DOM focus (only onFocus/onBlur touch it). `dismissed` is the
|
|
// separate "the user closed the panel" flag (Escape / after a pick) — kept apart so closing the
|
|
// panel never desyncs from actual focus: typing again clears `dismissed` and the panel re-opens
|
|
// without needing a blur+refocus cycle.
|
|
const [focused, setFocused] = useState(false);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const listId = useId();
|
|
|
|
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
|
|
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
|
|
const suggestEnabled =
|
|
focused && !dismissed && dq.length >= MIN_CHARS && activeField !== "episode";
|
|
const suggestQ = useQuery({
|
|
queryKey: qk.plexSuggest(scope, dq, activeField),
|
|
enabled: suggestEnabled,
|
|
queryFn: () => api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
|
staleTime: 30_000,
|
|
});
|
|
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
|
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
|
|
|
// A fresh query resets the highlight.
|
|
useEffect(() => setHi(-1), [dq, activeField, scope]);
|
|
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
|
useEffect(() => {
|
|
if (hi < 0) return;
|
|
listRef.current?.querySelector(`[data-idx="${hi}"]`)?.scrollIntoView({ block: "nearest" });
|
|
}, [hi]);
|
|
|
|
const pick = (i: number) => {
|
|
const c = suggestions[i];
|
|
if (!c) return;
|
|
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
|
|
setDismissed(true); // close the panel; typing again re-opens it (see `dismissed`)
|
|
setHi(-1);
|
|
};
|
|
|
|
const fields = availablePlexFields(scope);
|
|
const pillLabel = t(plexFieldLabelKey(activeField));
|
|
// Only reference an option while the listbox is actually open — a dangling aria-activedescendant
|
|
// on a collapsed combobox is an inconsistent a11y state.
|
|
const activeOptionId = open && hi >= 0 ? `${listId}-opt-${hi}` : undefined;
|
|
|
|
return (
|
|
<SearchBar
|
|
value={q}
|
|
placeholder={t("plex.searchPlaceholder")}
|
|
// Typing after a dismiss (Escape / pick) re-opens the panel.
|
|
onChange={(v) => {
|
|
setDismissed(false);
|
|
setQ(v);
|
|
}}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
setDismissed(false);
|
|
}}
|
|
onBlur={() => setFocused(false)}
|
|
onKeyDown={(e) => {
|
|
if (!open) return;
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setHi((h) => Math.min(h + 1, suggestions.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setHi((h) => Math.max(h - 1, -1));
|
|
} else if (e.key === "Enter" && hi >= 0) {
|
|
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
|
pick(hi);
|
|
} else if (e.key === "Escape") {
|
|
setDismissed(true);
|
|
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
|
}
|
|
}}
|
|
inputProps={{
|
|
role: "combobox",
|
|
"aria-autocomplete": "list",
|
|
"aria-expanded": open,
|
|
"aria-controls": open ? listId : undefined,
|
|
"aria-activedescendant": activeOptionId,
|
|
}}
|
|
leading={
|
|
<ActionMenu
|
|
ariaLabel={t("plex.searchField.aria")}
|
|
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
|
trigger={
|
|
<>
|
|
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
|
|
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
|
</>
|
|
}
|
|
items={fields.map((f) => ({
|
|
id: f,
|
|
label: t(plexFieldLabelKey(f)),
|
|
onSelect: () => setField(f),
|
|
}))}
|
|
/>
|
|
}
|
|
below={
|
|
open ? (
|
|
<div
|
|
ref={listRef}
|
|
id={listId}
|
|
role="listbox"
|
|
aria-label={t("plex.suggest.aria")}
|
|
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
|
|
>
|
|
{suggestions.length === 0 && suggestQ.isFetching && (
|
|
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
|
)}
|
|
{suggestions.map((c, i) => (
|
|
<button
|
|
key={c.id}
|
|
id={`${listId}-opt-${i}`}
|
|
data-idx={i}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={hi === i}
|
|
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
|
onMouseEnter={() => setHi(i)}
|
|
onClick={() => pick(i)}
|
|
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
|
|
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
|
}`}
|
|
>
|
|
<img
|
|
src={c.thumb}
|
|
alt=""
|
|
loading="lazy"
|
|
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
|
|
{c.year != null && (
|
|
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
|
|
)}
|
|
{c.type === "show" ? (
|
|
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
|
) : (
|
|
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
);
|
|
}
|