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>.
69 lines
2.7 KiB
TypeScript
69 lines
2.7 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { api, type PlexCard } from "../lib/api";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { LoadingState } from "./ui/QueryState";
|
|
import { cardImage } from "../lib/plexImage";
|
|
import RemoteImage from "./ui/RemoteImage";
|
|
|
|
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
|
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
|
// render the same result rows, differing only in the per-row action. Callers keep their own chrome
|
|
// (search input, name field, close button) and supply `term` + `renderAction`; this owns the query,
|
|
// the loading/error/empty states, and the row markup so those can't drift between the two surfaces.
|
|
export default function PlexLibraryPicker({
|
|
scope,
|
|
term,
|
|
enabled = true,
|
|
emptyHint = null,
|
|
renderAction,
|
|
}: {
|
|
scope: string;
|
|
/** The raw search term; debounced internally. */
|
|
term: string;
|
|
/** Gate the query off (e.g. until a collection name is valid). When false, renders nothing. */
|
|
enabled?: boolean;
|
|
/** Shown while the (debounced) term is empty — a hint, or nothing. */
|
|
emptyHint?: ReactNode;
|
|
/** The trailing control for each result row (Add / Create-with / …). */
|
|
renderAction: (card: PlexCard) => ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const debounced = useDebounced(term.trim(), 300);
|
|
const q = useQuery({
|
|
queryKey: qk.plexLibrary("collection-pick", scope, debounced),
|
|
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
|
enabled: enabled && debounced.length > 0,
|
|
});
|
|
const results = q.data?.items ?? [];
|
|
|
|
if (!enabled) return null;
|
|
if (debounced.length === 0) return <>{emptyHint}</>;
|
|
if (q.isLoading) return <LoadingState shape="list" label={t("plex.loading")} />;
|
|
if (q.isError) return <p className="text-muted text-sm">{t("common.loadError")}</p>;
|
|
if (results.length === 0)
|
|
return <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>;
|
|
|
|
return (
|
|
<ul className="flex flex-col gap-1">
|
|
{results.map((c) => (
|
|
<li key={c.id} className="flex items-center gap-3">
|
|
<RemoteImage
|
|
{...cardImage(c.thumb)}
|
|
alt=""
|
|
className="h-12 w-8 shrink-0 rounded object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-sm">
|
|
{c.title}
|
|
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
|
</span>
|
|
{renderAction(c)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|