From 1e894d378d20f2f28055afb149cac707218e6a27 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 08:00:54 +0200 Subject: [PATCH] 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. --- .../src/components/PlexCollectionView.tsx | 64 ++++++------------- frontend/src/components/PlexLibraryPicker.tsx | 60 +++++++++++++++++ frontend/src/components/plexBrowseLists.tsx | 55 +++++----------- 3 files changed, 95 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/PlexLibraryPicker.tsx diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index e7496b67..65788245 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -6,10 +6,10 @@ import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; -import { useDebounced } from "../lib/useDebounced"; import { useConfirm } from "./ConfirmProvider"; import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; import { PlexPosterCard } from "./plexCards"; +import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./QueryState"; @@ -342,14 +342,7 @@ function AddItemPanel({ }) { const { t } = useTranslation(); const [term, setTerm] = useState(""); - const debounced = useDebounced(term.trim(), 300); const [added, setAdded] = useState>(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) { try { @@ -381,43 +374,24 @@ function AddItemPanel({ - {debounced.length === 0 ? ( -

{t("plex.collection.addHint")}

- ) : q.isLoading ? ( - - ) : q.isError ? ( -

{t("common.loadError")}

- ) : results.length === 0 ? ( -

{t("plex.collection.addNoResults")}

- ) : ( -
    - {results.map((c) => { - const inColl = existing.has(c.id) || added.has(c.id); - return ( -
  • - - - {c.title} - {c.year != null && · {c.year}} - - -
  • - ); - })} -
- )} + {t("plex.collection.addHint")}

} + renderAction={(c) => { + const inColl = existing.has(c.id) || added.has(c.id); + return ( + + ); + }} + /> ); } diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx new file mode 100644 index 00000000..25c0ba51 --- /dev/null +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -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 ; + if (q.isError) return

{t("common.loadError")}

; + if (results.length === 0) return

{t("plex.collection.addNoResults")}

; + + return ( +
    + {results.map((c) => ( +
  • + + + {c.title} + {c.year != null && · {c.year}} + + {renderAction(c)} +
  • + ))} +
+ ); +} diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index e8f54a3a..c135ce25 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -17,6 +17,7 @@ import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState, StateMessage } from "./QueryState"; 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 // collections have no movie runtime). @@ -86,7 +87,6 @@ function NewCollectionPanel({ const [name, setName] = useState(""); const [term, setTerm] = useState(""); const [busy, setBusy] = useState(false); - const debounced = useDebounced(term.trim(), 300); const trimmed = name.trim(); // 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 @@ -94,12 +94,6 @@ function NewCollectionPanel({ // 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 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) { 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" /> - {!nameOk || debounced.length === 0 ? null : q.isLoading ? ( - - ) : q.isError ? ( -

{t("common.loadError")}

- ) : results.length === 0 ? ( -

{t("plex.collection.addNoResults")}

- ) : ( -
    - {results.map((c) => ( -
  • - - - {c.title} - {c.year != null && · {c.year}} - - -
  • - ))} -
- )} + ( + + )} + /> ); }