import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ListPlus, Plus, Youtube } from "lucide-react"; import { MenuCheck } from "./ui/MenuCheck"; import { api, type Playlist } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; 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. Same shape as the channel tag editor (R8 S1's pilot): radio-style // toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and // the input swallowing non-Escape keys, since character and arrow keys belong to the text field. 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); return ( <> { e.preventDefault(); e.stopPropagation(); }, })} label={t("playlists.addToPlaylist")} > ); } /** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) 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 fade = useScrollFade(); const open = pop.open; const [newName, setNewName] = useState(""); 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. 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); } catch (e) { notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); } finally { refresh(); setBusy(false); } } async function createAndAdd() { const name = newName.trim(); if (!name || busy) return; setBusy(true); try { const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); setNewName(""); } catch (e) { notifyActionError(e, t("playlists.addFailed")); } finally { refresh(); 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 ( <> {/* A React portal still propagates events up the REACT tree, so a click inside this panel would otherwise reach the feed card that hosts the trigger and start playback. */}
e.stopPropagation()}>
{t("playlists.addToPlaylist")}
{lists.length === 0 && !membership.isLoading && (
{t("playlists.noneYet")}
)} {lists.map((pl) => ( toggle(pl)} // NOT `disabled={busy}`: the native disabled attribute would blur the focused item to // mid-request and permanently kill roving focus (useListNavigation listens on // the floating element only). `toggle()` already no-ops while busy, so re-entrancy is // guarded without touching focus; `aria-busy` on the list conveys the pending state. label={playlistName(pl, t)} className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" > {playlistName(pl, t)} {(pl.source === "youtube" || pl.yt_playlist_id) && ( )} ))}
setNewName(e.target.value)} onKeyDown={(e) => { // Escape bubbles to the popover's dismiss; every other key is text editing here and // must not reach the surrounding menu's list navigation. if (e.key === "Escape") return; e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); void createAndAdd(); } }} placeholder={t("playlists.newPlaylist")} />
); }