import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, type PlexCollection } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { MembershipMenu } from "./ui/MembershipMenu"; 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. Its body is // the same MembershipMenu as the video card's "Add to playlist". 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 [busy, setBusy] = useState(false); const { ratingKey } = target; const forKey = qk.plexCollections("for", ratingKey); const list = useQuery({ queryKey: forKey, queryFn: () => api.plexCollectionsFor(ratingKey), }); // The title's own page (a movie's item, a show's show), 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.plexShow(ratingKey) }); void qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } // A write that landed flips the tick at once: the row's state comes from the list, and a second tap // before its refetch returned would otherwise repeat the write instead of undoing it. 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); qc.setQueryData(forKey, (old) => flipContains(old, c.id)); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } refresh(); setBusy(false); } async function create(name: string) { if (busy) return false; setBusy(true); let made = false; try { // No library: the server takes the seed title's own. await api.plexCreateCollection("", name, ratingKey); made = true; } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } refresh(); setBusy(false); return made; } const collections = list.data?.collections ?? []; return ( ({ id: c.id, label: c.title, checked: c.contains }))} onToggle={(row) => { const c = collections.find((x) => x.id === row.id); if (c) void toggle(c); }} status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"} onRetry={() => void list.refetch()} emptyText={t("plex.collectionAdd.none")} errorText={t("plex.collectionAdd.loadFailed")} busy={busy} create={{ placeholder: t("plex.collection.newCollection"), ariaLabel: t("plex.collection.namePlaceholder"), onCreate: create, }} /> ); } function flipContains( old: T | undefined, id: string, ): T | undefined { if (!old) return old; return { ...old, collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)), }; }