Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
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 { notifyActionError } from "../lib/actionErrors";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { playlistName } from "../lib/playlistName";
|
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|
import { IconButton } from "./ui/IconButton";
|
|
|
|
// 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,
|
|
variant = "ghost",
|
|
size = "sm",
|
|
}: {
|
|
videoId: string;
|
|
variant?: "ghost" | "outline" | "overlay";
|
|
size?: "sm" | "md";
|
|
}) {
|
|
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() });
|
|
}
|
|
|
|
// 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.
|
|
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);
|
|
} catch (e) {
|
|
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
|
} finally {
|
|
refresh();
|
|
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("");
|
|
} catch (e) {
|
|
notifyActionError(e, t("playlists.addFailed"));
|
|
} finally {
|
|
refresh();
|
|
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 (
|
|
<>
|
|
<IconButton
|
|
ref={pop.refs.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>
|
|
|
|
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl 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.addToPlaylist")}</div>
|
|
<div
|
|
ref={fade.ref}
|
|
style={fade.style}
|
|
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"
|
|
>
|
|
<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">{playlistName(pl, t)}</span>
|
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
|
<Youtube 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
|
|
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")}
|
|
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PopoverSurface>
|
|
</>
|
|
);
|
|
}
|