import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type PlexCollection } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { MenuCheck } from "./ui/MenuCheck"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; // "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title // into or out of its library's manual collections, and names a new one — created with this title in // it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new // collection, its name, a search for the title, then another search to add the next one. The panel is // the video card's "Add to playlist" (AddToPlaylist), so it keeps that file's two conventions: // typeahead off, and the name field keeping every key but Escape to itself. export interface PlexCollectionTarget { ratingKey: string; anchor: Element; } export default function PlexCollectionAddAt({ target, onClose, }: { target: PlexCollectionTarget; onClose: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const pop = useAnchoredPopover({ open: true, onOpenChange: (next) => { if (!next) onClose(); }, offset: 6, hostsInput: true, }); const { setPositionReference } = pop.refs; useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]); const { ref: fadeRef, style: fadeStyle } = useScrollFade(); const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); const { ratingKey } = target; const list = useQuery({ queryKey: qk.plexCollections("for", ratingKey), queryFn: () => api.plexCollectionsFor(ratingKey), }); // The title's own page, the collections' pages and the library grid all show membership. function refresh() { void qc.invalidateQueries({ queryKey: qk.plexCollections() }); void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) }); void qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } async function toggle(c: PlexCollection & { contains: boolean }) { if (busy) return; setBusy(true); try { if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey); else await api.plexCollectionAddItem(c.id, ratingKey); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } refresh(); setBusy(false); } async function create() { const name = newName.trim(); if (!name || busy) return; setBusy(true); try { // No library: the server takes the seed title's own. await api.plexCreateCollection("", name, ratingKey); setNewName(""); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } refresh(); setBusy(false); } const rows = list.data?.collections ?? []; return ( {/* A portal still bubbles through the React tree: keep a click here off the card that opened it. */}
e.stopPropagation()}>
{t("plex.collectionAdd.title")}
{list.isError ? (
{t("plex.collectionAdd.loadFailed")}
) : rows.length === 0 && !list.isLoading ? (
{t("plex.collectionAdd.none")}
) : null} {rows.map((c) => ( void toggle(c)} // Not `disabled={busy}`: that blurs the focused row and ends roving focus (see // AddToPlaylist); toggle() no-ops while busy and the list says aria-busy. label={c.title} className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" > {c.title} ))}
setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") return; e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); void create(); } }} placeholder={t("plex.collection.newCollection")} aria-label={t("plex.collection.namePlaceholder")} />
); }