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, 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 "./QueryState"; import PosterTile from "./PosterTile"; // 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): 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); if (rt) parts.push(rt); return parts.join(" · "); } // A read-only collection (a smart or external auto-list — imdb/tmdb/…) can never be edited; mark it up // front on the tile, mirroring the badge on the info page. function isReadOnlyCollection(c: PlexCollection): boolean { return !!c.smart || (!!c.source && c.source !== "collection"); } // 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) => ( } 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 debounced = useDebounced(term.trim(), 300); const trimmed = name.trim(); const dup = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase()); const nameOk = trimmed.length > 0 && !dup; 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; setBusy(true); try { const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library onCreated(col.id); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); setBusy(false); } } return (

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

setName(e.target.value)} placeholder={t("plex.collection.namePlaceholder")} aria-label={t("plex.collection.newCollection")} className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> {dup ? (

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

) : (

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

)}
setTerm(e.target.value)} disabled={!nameOk} 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 disabled:opacity-50" />
{!nameOk || debounced.length === 0 ? null : q.isLoading ? ( ) : results.length === 0 ? (

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

) : (
    {results.map((c) => (
  • {c.title} {c.year != null && · {c.year}}
  • ))}
)}
); } // 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 } = 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")} className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> {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) => ( ) : undefined } onClick={() => onPick(c.id, c.title)} className="h-28" /> ))}
)}
); }