import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors"; 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"; // The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the // ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external, // admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation // endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart // / imdb / tmdb / … auto-lists) are marked with a lock. function shuffled(a: string[]): string[] { const r = [...a]; for (let i = r.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = r[i]!; r[i] = r[j]!; r[j] = tmp; } return r; } export default function PlexCollectionView({ collectionId, onBack, onPlay, onOpenCard, onInfoCard, }: { collectionId: string; onBack: () => void; onPlay: (rk: string, queue: string[]) => void; onOpenCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const q = useQuery({ queryKey: qk.plexCollections("detail", collectionId), queryFn: () => api.plexCollection(collectionId), }); const d = q.data; const [expanded, setExpanded] = useState(false); const [renaming, setRenaming] = useState(false); const [name, setName] = useState(""); const [adding, setAdding] = useState(false); const [busy, setBusy] = useState(false); // Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle // meanwhile so a double-tap can't fire two queues. const [queueBusy, setQueueBusy] = useState(false); const invalidate = () => { qc.invalidateQueries({ queryKey: qk.plexCollections() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); }; const items = d?.items ?? []; // Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so // Play-all expands it into its episodes (buildQueue). Either kind means there's something to play — // this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's // Play-all was empty and did nothing. const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id); const hasShows = items.some((c) => c.type === "show"); const canPlay = leafKeys.length > 0 || hasShows; // Build the ordered play queue: leaf members as themselves, each show member expanded into its // episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click), // in parallel, but concatenated in member order so the queue follows the grid — not on page load, // which would fetch every show up front for a button that may never be pressed. async function buildQueue(): Promise { const perMember = await Promise.all( items.map(async (c) => { if (c.type !== "show") return [c.id]; const detail = await qc.fetchQuery({ queryKey: qk.plexShow(c.id), queryFn: () => api.plexShow(c.id), }); return detail.seasons.flatMap((s) => s.episodes.map((e) => e.id)); }), ); return perMember.flat(); } async function playAll(shuffle: boolean) { setQueueBusy(true); try { let queue = await buildQueue(); if (shuffle) queue = shuffled(queue); if (queue.length > 0) onPlay(queue[0]!, queue); } catch (e) { notifyActionError(e, t("plex.collection.playFailed")); } finally { setQueueBusy(false); } } const canEdit = isAdmin && !!d?.can_edit; // A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares // isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them. const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d); const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list const toggleWatched = (card: PlexCard) => toggleWatchState(card, t, { optimistic: (next) => { qc.setQueryData(qk.plexCollections("detail", collectionId), (old) => old ? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)), } : old, ); }, resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), }); async function doRename() { const title = name.trim(); if (!title || !d || title === d.title) { setRenaming(false); return; } setBusy(true); try { await api.plexRenameCollection(collectionId, title); invalidate(); setRenaming(false); } catch (e) { // The backend enforces per-library name uniqueness — localize its 409 to nameTaken. notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); } finally { setBusy(false); } } async function doDelete() { if (!d) return; if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) }))) return; setBusy(true); try { await api.plexDeleteCollection(collectionId); invalidate(); onBack(); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); setBusy(false); } } async function doRemove(card: PlexCard) { // Write-through to the shared Plex library — confirm first, matching the delete-collection guard. if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) }))) return; setBusy(true); try { await api.plexCollectionRemoveItem(collectionId, card.id); invalidate(); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } finally { setBusy(false); } } async function enableEdit() { setBusy(true); try { await api.plexSetCollectionEditable(collectionId, true); invalidate(); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } finally { setBusy(false); } } const meta = [ d?.year_min != null && (d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`), d?.content_rating, d && t("plex.browse.items", { count: d.child_count ?? items.length }), formatRuntime(d?.total_runtime_s), ].filter(Boolean) as string[]; return (
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !d ? ( ) : ( <> canPlay && !queueBusy && playAll(false)} customizeMenu={null} > {renaming ? ( setName(e.target.value)} onBlur={doRename} onKeyDown={(e) => { if (e.key === "Enter") doRename(); if (e.key === "Escape") setRenaming(false); }} aria-label={t("plex.collection.rename")} className="glass-card w-full rounded-lg px-2 py-1 text-2xl font-semibold focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> ) : (

{d.title}

{readOnly && ( {t("plex.collection.readOnly")} )}
)} {meta.length > 0 && (
{meta.map((m, i) => ( {i > 0 && "· "} {m} ))}
)} {d.summary && (

{d.summary}

)}
{canPlay && ( <> playAll(false)} disabled={queueBusy} icon={Play} label={t("plex.collection.playAll")} /> playAll(true)} disabled={queueBusy} icon={Shuffle} label={t("plex.collection.shuffle")} /> )} {canEdit && ( <> { setName(d.title); setRenaming(true); }} icon={Pencil} label={t("plex.collection.rename")} /> setAdding((v) => !v)} icon={Plus} label={t("plex.collection.addItem")} /> )} {canEnableEdit && ( )}
{adding && canEdit && ( c.type === "show") ? "show" : "movie"} existing={new Set(items.map((c) => c.id))} onAdded={invalidate} onClose={() => setAdding(false)} /> )} {items.length === 0 ? (

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

) : (
{items.map((c) => (
onOpenCard(c)} onInfo={() => onInfoCard(c)} onToggleWatched={toggleWatched} /> {canEdit && ( )}
))}
)} )}
); } // A compact search-and-add panel: search the collection's library, click a result to add it (write- // through to Plex). Stays open so several items can be added in a row; already-present items are marked. function AddItemPanel({ collectionId, scope, existing, onAdded, onClose, }: { collectionId: string; scope: string; existing: Set; onAdded: () => void; onClose: () => void; }) { const { t } = useTranslation(); const [term, setTerm] = useState(""); const [added, setAdded] = useState>(new Set()); async function add(card: PlexCard) { try { await api.plexCollectionAddItem(collectionId, card.id); setAdded((s) => new Set(s).add(card.id)); onAdded(); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } } return (
setTerm(e.target.value)} placeholder={t("plex.collection.addSearch")} aria-label={t("plex.collection.addSearch")} className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" />
{t("plex.collection.addHint")}

} renderAction={(c) => { const inColl = existing.has(c.id) || added.has(c.id); return ( ); }} />
); }