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>.
188 lines
8.2 KiB
TypeScript
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
|
|
}
|
|
/>
|
|
);
|
|
}
|