import { useState } from "react"; import { useTranslation } from "react-i18next"; import { vocab } from "../lib/vocab"; 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 { plexLang } from "../i18n"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { notify } from "../lib/notifications"; import { showEpisodeKeys } from "../lib/plexQueue"; import { healPlexSort, plexSortOptions } from "../lib/plexSort"; import { usePref } from "../lib/usePref"; import SortControl from "./ui/SortControl"; import { type SortState } from "../lib/columnSort"; import { useConfirm } from "./confirmContext"; import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi"; import { PlexPosterCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; import { Button } from "./ui/Button"; import { ClampedText } from "./ui/ClampedText"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; import { useReturnFocus } from "./ui/useReturnFocus"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { PageHeading } from "./ui/Headings"; // 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. // The order this page had before it grew a Sort control, and still its default: a franchise reads in // release order. MUST match the endpoint's `DEFAULT_MEMBER_SORT` — the two only diverge if a request // goes out without the params, which is why the control always sends them. const COLLECTION_SORT_DEFAULT: NonNullable = { key: "year", dir: "asc" }; 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, onRestartCard, onInfoCard, }: { collectionId: string; onBack: () => void; onPlay: (rk: string, queue: string[]) => void; onOpenCard: (card: PlexCard) => void; onRestartCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // One menu for the member grid (see ui/ContextMenu). const menu = useContextMenu(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; // The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the // same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key // rather than the browse grid's sort: opening a collection must not silently re-sort the library // behind it. const [sortPref, setSortPref] = usePref>( "plexCollectionSort", COLLECTION_SORT_DEFAULT, ); // ONE key for the read and every write into it: when the sort joined the key, the watched toggle's // optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches // exactly), so the click did nothing visible until a reload. const lang = plexLang(i18n.language); const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang); const q = useQuery({ queryKey: detailKey, queryFn: () => api.plexCollection(collectionId, sortPref, lang), // A re-sort keeps the current page on screen while the new order loads. Without it every sort // change was a key with no data yet, and the whole page — the Sort control and its open panel // with it — was swapped for a spinner, so key and direction could not be set in one visit. Only // within the SAME collection: stepping to another one must not show this one's hero meanwhile. placeholderData: (prev, prevQuery) => prevQuery?.queryKey[2] === collectionId ? prev : undefined, }); const d = q.data; const [renaming, setRenaming] = useState(false); const renameFocus = useReturnFocus(renaming); 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 ?? []; // A collection lives in ONE library, so its members are all movies or all shows. That decides both // what the add-panel searches and which sort keys are on offer (only a movie has a runtime). const scope = items.some((c) => c.type === "show") ? "show" : "movie"; const sortState = healPlexSort(sortPref, scope); const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang); // 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, lang), queryFn: () => api.plexShow(c.id, lang), }); return showEpisodeKeys(detail); }), ); return perMember.flat(); } async function playAll(shuffle: boolean) { setQueueBusy(true); try { let queue = await buildQueue(); if (shuffle) queue = shuffled(queue); // canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched / // empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly. if (queue.length > 0) onPlay(queue[0]!, queue); else notify({ level: "info", message: t("plex.collection.playEmpty") }); } 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(detailKey, (old) => old ? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)), } : old, ); }, // This page's OWN key too, not just the library's: the flip above lands on the entry the page // reads, and resync is what corrects it to the truth when the toggle fails — invalidating only // `plex-library` left a failed toggle flipped on screen. The prefix (no sort in it) also // refreshes the other sort orders of this collection cached behind this one. resync: () => { qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) }); }, }); const actions = usePlexCardActions( { onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggleWatched, }, menu.open, ); 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 — a taken name comes back localized. notifyActionError(e, t("plex.collection.editFailed")); } 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 && vocab(t, "certificate", d.content_rating), d && t("plex.browse.items", { count: d.child_count ?? items.length }), formatRuntime(d?.total_runtime_s, lang), ].filter(Boolean) as string[]; return (
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !d ? ( ) : ( <> canPlay && !queueBusy && playAll(false)} // Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short // description sat beside a show-sized poster, two thirds of the hero empty under them. posterSizeClass="w-20 sm:w-24" customizeMenu={null} > {renaming ? ( setName(e.target.value)} onBlur={doRename} onKeyDown={(e) => { if (e.key === "Enter") { renameFocus.arm(e); doRename(); } if (e.key === "Escape") { renameFocus.arm(e); setRenaming(false); } }} aria-label={t("plex.collection.rename")} /> ) : (
{d.title} {readOnly && ( {t("plex.collection.readOnly")} )}
)} {meta.length > 0 && (
{meta.map((m, i) => ( {i > 0 && "· "} {m} ))}
)} {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.id))} onAdded={invalidate} onClose={() => setAdding(false)} /> )} {items.length === 0 ? (

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

) : ( <> {/* Sort sits directly above the grid, pinned right — the same placement (and the same control, options and labels) as the browse grid, so the two read as one idea. */}
setSortPref(next ?? COLLECTION_SORT_DEFAULT)} label={t("plex.filter.sort")} ascLabel={t("plex.filter.dir.asc")} descLabel={t("plex.filter.dir.desc")} />
{/* Two columns on a phone, like every other poster grid (≥140px a card): three left an ~85px card whose title shared its line with the touch "…" (E28 C1). */} {items.map((c, i) => ( onOpenCard(c)} onRestart={() => onRestartCard(c)} onToggleWatched={toggleWatched} onAdd={actions.add} menuProps={menu.triggerProps} menuButtonProps={menu.buttonProps} menuOpen={menu.open && menu.target === c} /> ))} )} )} canEdit ? [ { // A write to the shared Plex library: a menu row since E28 C1 (the card's hover // row owns its corner now), still behind the confirmation in `doRemove`. id: "collection-remove", label: t("plex.collection.removeItem"), icon: , separatorBefore: true, danger: true, disabled: busy, onSelect: () => void doRemove(c), }, ] : [] } />
); } // 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")} />
{t("plex.collection.addHint")}

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