fix(ui): one membership menu for playlists, collections and tags

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.
This commit is contained in:
2026-10-08 21:18:48 +02:00
parent fadb4f17a9
commit 7e94da72b9
7 changed files with 416 additions and 220 deletions
+48 -77
View File
@@ -2,23 +2,19 @@ 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 } from "lucide-react";
import { ListPlus } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
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 { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
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. Same shape as the channel tag editor (the popover 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.
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
export default function AddToPlaylist({
videoId,
variant = "ghost",
@@ -94,9 +90,7 @@ function MembershipPanel({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
@@ -115,13 +109,17 @@ function MembershipPanel({
// 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.
// 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"));
}
@@ -129,19 +127,20 @@ function MembershipPanel({
setBusy(false);
}
async function createAndAdd() {
const name = newName.trim();
if (!name || busy) return;
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);
setNewName("");
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
@@ -149,67 +148,39 @@ function MembershipPanel({
const lists = membership.data ?? [];
return (
<>
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* 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. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fadeRef}
style={fadeStyle}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
// A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video}
onSelect={() => toggle(pl)}
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> 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"
>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div 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
className="flex-1"
size="sm"
value={newName}
onChange={(e) => 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")}
/>
</div>
</div>
</PopoverSurface>
</>
<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>
);
}