import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ListPlus } from "lucide-react"; import { YouTubeIcon } from "../lib/brandIcons"; import { api, type Playlist } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { playlistName } from "../lib/playlistName"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { IconButton } from "./ui/IconButton"; import { MembershipMenu } from "./ui/MembershipMenu"; import { SURFACE } from "./ui/surfaces"; // A small anchored menu for toggling a video's membership in the user's playlists, with inline // "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). export default function AddToPlaylist({ videoId, variant = "ghost", size = "sm", }: { videoId: string; variant?: "ghost" | "outline" | "overlay" | "media"; size?: "sm" | "md"; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const pop = useMembershipPopover(open, setOpen); const { setReference } = pop.refs; return ( <> { e.preventDefault(); e.stopPropagation(); }, })} label={t("playlists.addToPlaylist")} > ); } /** The same panel, opened from an object's menu ("Add to playlist…") rather than its own * button: anchored to the element the menu came from, and gone when it closes. */ export function AddToPlaylistAt({ videoId, anchor, onClose, }: { videoId: string; anchor: Element; onClose: () => void; }) { const pop = useMembershipPopover(true, (next) => { if (!next) onClose(); }); const { setPositionReference } = pop.refs; useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]); return ; } function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) { return useAnchoredPopover({ open, onOpenChange, offset: 6, // The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the // roving list so they don't steal the caret. hostsInput: true, }); } function MembershipPanel({ videoId, pop, }: { videoId: string; pop: ReturnType; }) { const { t } = useTranslation(); const qc = useQueryClient(); const open = pop.open; const [busy, setBusy] = useState(false); const membership = useQuery({ queryKey: qk.playlistMembership(videoId), queryFn: () => api.playlists(videoId), enabled: open, }); function refresh() { qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) }); qc.invalidateQueries({ queryKey: qk.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: qk.playlist() }); } // A failure is said, and the list refreshed either way. With no catch, a playlist deleted on // another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an // unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that // landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it. 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); qc.setQueryData(qk.playlistMembership(videoId), (old) => old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)), ); } catch (e) { notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); } refresh(); setBusy(false); } async function createAndAdd(name: string) { if (busy) return false; setBusy(true); let made = false; try { const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); made = true; } catch (e) { notifyActionError(e, t("playlists.addFailed")); } refresh(); setBusy(false); return made; } // 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 ( ({ id: pl.id, label: playlistName(pl, t), checked: !!pl.has_video, extra: pl.source === "youtube" || pl.yt_playlist_id ? ( ) : undefined, }))} onToggle={(row) => { const pl = lists.find((p) => p.id === row.id); if (pl) void toggle(pl); }} status={ membership.isError && !membership.data ? "error" : membership.isLoading ? "loading" : "ready" } onRetry={() => void membership.refetch()} emptyText={t("playlists.noneYet")} errorText={t("common.loadError")} busy={busy} create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }} /> ); }