import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // 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, className, }: { videoId: string; className?: string; }) { const { t } = useTranslation(); const qc = useQueryClient(); const fade = useScrollFade(); const [open, setOpen] = useState(false); const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); const pop = useAnchoredPopover({ open, onOpenChange: setOpen, 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, }); 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() }); } 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() { 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 ( <> {/* 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-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]" > {pl.has_video && } {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")} className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" />
); }