lib/useDismiss.ts replaces the identical mousedown-outside + Escape effect that DataTable (filter popover), Channels (tag picker) and AddToPlaylist each hand-rolled. Positioning stays with each caller (it genuinely varies); AddToPlaylist keeps its own resize/scroll reposition effect.
185 lines
6.6 KiB
TypeScript
185 lines
6.6 KiB
TypeScript
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";
|
|
import { useDismiss } from "../lib/useDismiss";
|
|
|
|
// 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<HTMLButtonElement | null>(null);
|
|
const panelRef = useRef<HTMLDivElement | null>(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);
|
|
}
|
|
|
|
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
|
|
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
window.addEventListener("resize", place);
|
|
window.addEventListener("scroll", place, true);
|
|
return () => {
|
|
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 (
|
|
<>
|
|
<button
|
|
ref={triggerRef}
|
|
onClick={toggleOpen}
|
|
title={t("playlists.addToPlaylist")}
|
|
aria-label={t("playlists.addToPlaylist")}
|
|
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
|
>
|
|
<ListPlus className="w-4 h-4" />
|
|
</button>
|
|
|
|
{open &&
|
|
createPortal(
|
|
<div
|
|
ref={panelRef}
|
|
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
|
|
className="z-50 glass-menu rounded-xl p-2 shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="text-xs text-muted px-1.5 pb-1.5">
|
|
{t("playlists.addToPlaylist")}
|
|
</div>
|
|
<div className="max-h-56 overflow-y-auto">
|
|
{lists.length === 0 && !membership.isLoading && (
|
|
<div className="text-xs text-muted px-1.5 py-2">
|
|
{t("playlists.noneYet")}
|
|
</div>
|
|
)}
|
|
{lists.map((pl) => (
|
|
<button
|
|
key={pl.id}
|
|
onClick={() => toggle(pl)}
|
|
disabled={busy}
|
|
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
|
|
>
|
|
<span
|
|
className={`grid place-items-center w-4 h-4 rounded border ${
|
|
pl.has_video
|
|
? "bg-accent border-accent text-accent-fg"
|
|
: "border-border"
|
|
}`}
|
|
>
|
|
{pl.has_video && <Check className="w-3 h-3" />}
|
|
</span>
|
|
<span className="truncate flex-1 text-left">
|
|
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
|
</span>
|
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
|
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<form
|
|
onSubmit={createAndAdd}
|
|
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
|
|
>
|
|
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
|
<input
|
|
value={newName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</form>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|