Files
siftlode/frontend/src/components/PlexSearchBox.tsx
T
peter 383d394e32 fix(ui): a picture that fails to load shows the card's own frame, then retries
A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
2026-10-14 20:51:08 +02:00

188 lines
8.2 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 "./ui/ActionMenu";
import { usePlex, usePlexActions } from "./plexContext";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover";
import { cardImage } from "../lib/plexImage";
import RemoteImage from "./ui/RemoteImage";
// The Plex header search box: the shared SearchBar enriched with the 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 — during THAT render, so no frame ever draws the new
// suggestion list with the old row highlighted.
const suggestKey = `${scope}|${activeField}|${dq}`;
const [hiFor, setHiFor] = useState(suggestKey);
if (hiFor !== suggestKey) {
setHiFor(suggestKey);
setHi(-1);
}
// 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") {
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
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-full px-2 h-7 text-xs font-medium text-muted transition state-layer hover:text-fg touch-target"
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
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-menu mt-2 max-h-[70dvh] overflow-y-auto animate-[popIn_0.16s_ease]`}
>
{/* Beside the listbox, not in it: a listbox may own options only. */}
{suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
)}
<div ref={listRef} id={listId} role="listbox" aria-label={t("plex.suggest.aria")}>
{suggestions.map((c, i) => (
<Pressable
key={c.id}
id={`${listId}-opt-${i}`}
data-idx={i}
role="option"
aria-selected={hi === i}
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)}
onClick={() => pick(i)}
// The pointer moves `hi` too, so the highlight IS the hover: the menus' roving
// recipe (AnchoredPopover's ROVING_HIGHLIGHT), one look for the mouse and the keys.
data-active={hi === i ? "true" : undefined}
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
>
<RemoteImage
{...cardImage(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" />
)}
</Pressable>
))}
</div>
</div>
) : null
}
/>
);
}