refactor(plex): extract shared PlexLibraryPicker (F6)
AddItemPanel (Collection page) and NewCollectionPanel (browse) had near- duplicate debounced library-search + result-list rendering. Extract it into one PlexLibraryPicker that owns the query, the loading/error/empty states and the row markup; callers keep their own chrome and pass term + renderAction. No behaviour change.
This commit is contained in:
@@ -6,10 +6,10 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
|
|
||||||
@@ -342,14 +342,7 @@ function AddItemPanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [term, setTerm] = useState("");
|
const [term, setTerm] = useState("");
|
||||||
const debounced = useDebounced(term.trim(), 300);
|
|
||||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||||
const q = useQuery({
|
|
||||||
queryKey: qk.plexLibrary("collection-add", scope, debounced),
|
|
||||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
|
||||||
enabled: debounced.length > 0,
|
|
||||||
});
|
|
||||||
const results = q.data?.items ?? [];
|
|
||||||
|
|
||||||
async function add(card: PlexCard) {
|
async function add(card: PlexCard) {
|
||||||
try {
|
try {
|
||||||
@@ -381,43 +374,24 @@ function AddItemPanel({
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{debounced.length === 0 ? (
|
<PlexLibraryPicker
|
||||||
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
|
scope={scope}
|
||||||
) : q.isLoading ? (
|
term={term}
|
||||||
<LoadingState label={t("plex.loading")} />
|
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
|
||||||
) : q.isError ? (
|
renderAction={(c) => {
|
||||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
const inColl = existing.has(c.id) || added.has(c.id);
|
||||||
) : results.length === 0 ? (
|
return (
|
||||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
<button
|
||||||
) : (
|
onClick={() => add(c)}
|
||||||
<ul className="flex flex-col gap-1">
|
disabled={inColl}
|
||||||
{results.map((c) => {
|
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||||
const inColl = existing.has(c.id) || added.has(c.id);
|
>
|
||||||
return (
|
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
<li key={c.id} className="flex items-center gap-3">
|
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||||
<img
|
</button>
|
||||||
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>
|
|
||||||
<button
|
|
||||||
onClick={() => add(c)}
|
|
||||||
disabled={inColl}
|
|
||||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
||||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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 "./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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { notify } from "../lib/notifications";
|
|||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import PosterTile from "./PosterTile";
|
import PosterTile from "./PosterTile";
|
||||||
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||||
|
|
||||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||||
// collections have no movie runtime).
|
// collections have no movie runtime).
|
||||||
@@ -86,7 +87,6 @@ function NewCollectionPanel({
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [term, setTerm] = useState("");
|
const [term, setTerm] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const debounced = useDebounced(term.trim(), 300);
|
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
||||||
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
||||||
@@ -94,12 +94,6 @@ function NewCollectionPanel({
|
|||||||
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
||||||
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
||||||
const nameOk = trimmed.length > 0;
|
const nameOk = trimmed.length > 0;
|
||||||
const q = useQuery({
|
|
||||||
queryKey: qk.plexLibrary("collection-new", debounced),
|
|
||||||
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
|
|
||||||
enabled: nameOk && debounced.length > 0,
|
|
||||||
});
|
|
||||||
const results = q.data?.items ?? [];
|
|
||||||
|
|
||||||
async function create(card: PlexCard) {
|
async function create(card: PlexCard) {
|
||||||
if (!nameOk || busy) return;
|
if (!nameOk || busy) return;
|
||||||
@@ -155,38 +149,21 @@ function NewCollectionPanel({
|
|||||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
|
<PlexLibraryPicker
|
||||||
<LoadingState label={t("plex.loading")} />
|
scope="both"
|
||||||
) : q.isError ? (
|
term={term}
|
||||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
enabled={nameOk}
|
||||||
) : results.length === 0 ? (
|
renderAction={(c) => (
|
||||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
<button
|
||||||
) : (
|
onClick={() => create(c)}
|
||||||
<ul className="flex flex-col gap-1">
|
disabled={busy}
|
||||||
{results.map((c) => (
|
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||||
<li key={c.id} className="flex items-center gap-3">
|
>
|
||||||
<img
|
<Check className="h-3.5 w-3.5" />
|
||||||
src={c.thumb}
|
{t("plex.collection.createWith")}
|
||||||
alt=""
|
</button>
|
||||||
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>
|
|
||||||
<button
|
|
||||||
onClick={() => create(c)}
|
|
||||||
disabled={busy}
|
|
||||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
{t("plex.collection.createWith")}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user