Files
siftlode/frontend/src/components/PlexLibraryPicker.tsx
T
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00

67 lines
2.6 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";
// 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 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">
<img
src={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>
);
}