import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; // A small popover (portaled to body, so the feed grid can't clip it) for toggling a // video's membership in the user's playlists, with inline "new playlist" creation. export default function AddToPlaylist({ videoId, className, }: { videoId: string; className?: string; }) { const { t } = useTranslation(); const qc = useQueryClient(); const triggerRef = useRef(null); const panelRef = useRef(null); const [open, setOpen] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); const membership = useQuery({ queryKey: ["playlist-membership", videoId], queryFn: () => api.playlists(videoId), enabled: open, }); // Re-place once the panel is actually rendered (so we know its real height) and whenever // its content — hence height — changes, so the flip-above decision uses the true size. useLayoutEffect(() => { if (open) place(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, membership.data]); function place() { const r = triggerRef.current?.getBoundingClientRect(); if (!r) return; const width = 240; const margin = 8; // Use the panel's real height once it's mounted; estimate on the first open. const h = panelRef.current?.offsetHeight ?? 300; let top = r.bottom + 6; if (top + h > window.innerHeight - margin) { // Not enough room below — open upward, clamped to the viewport. top = Math.max(margin, r.top - h - 6); } const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin); setCoords({ top: Math.round(top), left: Math.round(left) }); } function toggleOpen(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); if (!open) place(); setOpen((o) => !o); } useEffect(() => { if (!open) return; function onDoc(e: MouseEvent) { if ( !panelRef.current?.contains(e.target as Node) && !triggerRef.current?.contains(e.target as Node) ) setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false); } document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; }, [open]); function refresh() { qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); qc.invalidateQueries({ queryKey: ["playlists"] }); // Also invalidate every open/cached playlist detail (["playlist", id]) so the // Playlists page reflects the add/remove when navigated to, not only after F5. qc.invalidateQueries({ queryKey: ["playlist"] }); } async function toggle(pl: Playlist) { if (busy) return; setBusy(true); try { if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); else await api.addToPlaylist(pl.id, videoId); refresh(); } finally { setBusy(false); } } async function createAndAdd(e: React.FormEvent) { e.preventDefault(); const name = newName.trim(); if (!name || busy) return; setBusy(true); try { const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); setNewName(""); refresh(); } finally { setBusy(false); } } // All of the user's playlists are add targets, including YouTube-linked ones — adding // marks them dirty, and a later "Sync to YouTube" pushes the change back. const lists = membership.data ?? []; return ( <> {open && createPortal(
e.stopPropagation()} >
{t("playlists.addToPlaylist")}
{lists.length === 0 && !membership.isLoading && (
{t("playlists.noneYet")}
)} {lists.map((pl) => ( ))}
setNewName(e.target.value)} placeholder={t("playlists.newPlaylist")} className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" />
, document.body )} ); }