import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState, StateMessage } from "./ui/QueryState"; import PosterTile from "./PosterTile"; import PlexLibraryPicker from "./PlexLibraryPicker"; import NavGrid from "./ui/NavGrid"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only // collections have no movie runtime). function collectionMeta(c: PlexCollection, t: TFunction, lang: string): string { const parts: string[] = []; if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count })); const rt = formatRuntime(c.total_runtime_s, lang); if (rt) parts.push(rt); return parts.join(" · "); } // The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles, // each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the // title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections // union), so this is a pure front-end surface. Collection tiles show a poster collage of their top // films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they // take the colour fallback. const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"; // A grid of the user's Plex playlists; a tile opens that playlist's view. export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) { const { t } = useTranslation(); const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); const playlists = q.data?.playlists ?? []; if (q.isError && !q.data) { return ( q.refetch()}> {t("common.loadError")} ); } if (q.isLoading) return ; if (playlists.length === 0) return

{t("plex.playlist.none")}

; return ( {playlists.map((p, i) => ( } onClick={() => onOpen(p.id)} className="h-28" /> ))} ); } // The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one — // Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an // empty collection. Creating opens the new collection's detail page (onCreated → onPick). function NewCollectionPanel({ existingNames, onCreated, onClose, }: { existingNames: Set; onCreated: (rk: string) => void; onClose: () => void; }) { const { t } = useTranslation(); const [name, setName] = useState(""); const [term, setTerm] = useState(""); const [busy, setBusy] = useState(false); 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 // section). So a match here is a hint, not a hard block — it must not gate creation, or a valid // 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; async function create(card: PlexCard) { if (!nameOk || busy) return; setBusy(true); try { const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library onCreated(col.id); } catch (e) { // The backend is authoritative on per-library name uniqueness — a taken name comes back // structured, and so localized (see localizeDetail). notifyActionError(e, t("plex.collection.editFailed")); setBusy(false); } } return (

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

setName(e.target.value)} placeholder={t("plex.collection.namePlaceholder")} aria-label={t("plex.collection.newCollection")} /> {dupHint ? (

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

) : (

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

)}
setTerm(e.target.value)} disabled={!nameOk} placeholder={t("plex.collection.addSearch")} aria-label={t("plex.collection.addSearch")} />
( )} />
); } // A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page. export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const [search, setSearch] = useState(""); const [creating, setCreating] = useState(false); const searchDeb = useDebounced(search.trim(), 300); const q = useQuery({ queryKey: qk.plexCollections("union", searchDeb), queryFn: () => api.plexCollections(undefined, searchDeb || undefined), }); const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]); const existingNames = useMemo( () => new Set(collections.map((c) => c.title.trim().toLowerCase())), [collections], ); return (
setSearch(e.target.value)} placeholder={t("plex.filter.collectionSearch")} aria-label={t("plex.filter.collectionSearch")} /> {isAdmin && !creating && ( )}
{creating && ( { setCreating(false); onPick(rk, ""); }} onClose={() => setCreating(false)} /> )} {q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading ? ( ) : collections.length === 0 ? (

{t("plex.filter.collectionEmpty")}

) : ( {collections.map((c, i) => ( ) : undefined } onClick={() => onPick(c.id, c.title)} className="h-28" /> ))} )}
); }