Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page.
187 lines
6.0 KiB
TypeScript
187 lines
6.0 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")}
|
|
>
|
|
<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>
|
|
);
|
|
}
|