The loading / failed-load lines and their Retry sat inside role=menu, which may own its items only: the three surfaces now open with `listInside` and the rows sit in a PopoverList. A keyboard Retry unmounted the focused button and left focus on <body> with the menu open, the arrows dead: focus is put back on the first row, else the field, else the new Retry. The channel tag editor said a failed tag load as "no tags yet"; it reads the catalog query's state now. The playlist panel's name and the tag editor's error state are held by tests.
188 lines
6.1 KiB
TypeScript
188 lines
6.1 KiB
TypeScript
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 (
|
|
<>
|
|
<IconButton
|
|
ref={setReference}
|
|
variant={variant}
|
|
size={size}
|
|
{...pop.getReferenceProps({
|
|
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
|
// reach it.
|
|
onClick: (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
})}
|
|
label={t("playlists.addToPlaylist")}
|
|
>
|
|
<ListPlus />
|
|
</IconButton>
|
|
<MembershipPanel videoId={videoId} pop={pop} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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 <MembershipPanel videoId={videoId} pop={pop} />;
|
|
}
|
|
|
|
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<typeof useAnchoredPopover>;
|
|
}) {
|
|
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<Playlist[]>(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 (
|
|
<PopoverSurface
|
|
popover={pop}
|
|
className={`${SURFACE.popover} w-60 p-2`}
|
|
ariaLabel={t("playlists.addToPlaylistTitle")}
|
|
listInside
|
|
>
|
|
<MembershipMenu
|
|
heading={t("playlists.addToPlaylistTitle")}
|
|
rows={lists.map((pl) => ({
|
|
id: pl.id,
|
|
label: playlistName(pl, t),
|
|
checked: !!pl.has_video,
|
|
extra:
|
|
pl.source === "youtube" || pl.yt_playlist_id ? (
|
|
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
|
) : 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 }}
|
|
/>
|
|
</PopoverSurface>
|
|
);
|
|
}
|