feat(e10): open the app's menu with a right-click, starting with playlist rows

S2's headline was never a new Menu: `ActionMenu` already sits on `AnchoredPopover`
(arrows, typeahead, Escape, anchor-flip). What was missing is an INPUT PATH into it, so
this adds `useContextMenu` + `<ContextMenu>` over the same `ActionMenuItem[]` and the same
`MenuItemList` — a row cannot grow a right-click menu that looks or behaves unlike its own
⋯ menu. Three ways in: right-click anywhere on the row, long-press on touch/pen, and
Shift+F10 / the Menu key. The LEFT button is untouched and keeps the row's own action.

One popover per list, anchored to a virtual element at the pointer (native behaviour, and
a table of 200 rows would otherwise mount 200 floating contexts to keep 199 shut).

Platform bits it needed: `hoverAlso` on AnchoredPopover (a submenu falls open under a
mouse and is tapped by a finger — mouse-only, so a tap's synthetic mouseenter can't race
the click toggle) and `elementRef` on PopoverItem (the submenu anchors to the very
menuitem that opens it, where aria-haspopup/expanded belong). `ActionMenuItem` becomes a
discriminated union — a row either fires or opens a submenu — plus `danger` and
`separatorBefore`.

First consumers, both in Playlists:
- item rows: play · move to another list (a submenu of every other playlist, one gesture)
  · to top / up / down / bottom · remove. Reordering goes through the same undoable store
  as the drag handle and is disabled under a view for the same reason, with the same
  explanation rather than silence.
- rail rows: rename (in the row that was right-clicked, keeping its cover so the rail
  doesn't jump) · delete. Both now run through `usePlaylistActions`, which also takes over
  the detail header's pencil and trash — the delete dialog's YouTube branch and the
  "what gets selected next" rule exist once, not twice.
- the rail gains a "recently changed" sort key off the new `updated_at`. NOT the default:
  a rail that reshuffles itself while you look at it costs more than it saves.
This commit is contained in:
2026-09-05 19:59:56 +02:00
parent b28be682fe
commit 928eac4c89
11 changed files with 997 additions and 111 deletions
+141 -50
View File
@@ -21,8 +21,13 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
AlertTriangle,
ArrowDown,
ArrowDownToLine,
ArrowUp,
ArrowUpToLine,
Check,
ExternalLink,
FolderInput,
GripVertical,
ListPlus,
Pencil,
@@ -31,9 +36,10 @@ import {
RotateCcw,
Trash2,
Upload,
X,
Youtube,
} from "lucide-react";
import { api, HttpError, type Video } from "../lib/api";
import { api, type Playlist, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
@@ -43,10 +49,14 @@ import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
import { PageToolbar } from "./ui/PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import {
ColumnHead,
@@ -88,11 +98,16 @@ function Row({
columns,
dragDisabled,
dragHint,
menuProps,
}: {
video: Video;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
* behaviour — a right-click just isn't a button press. */
menuProps: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -112,6 +127,7 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
{...menuProps}
>
{columns.map((col) => (
<td
@@ -153,11 +169,10 @@ export default function Playlists() {
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const choose = useChoice();
const plActions = usePlaylistActions();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
@@ -202,6 +217,9 @@ export default function Playlists() {
// a change of view would otherwise slide a different video under the index — or unmount the player
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
const [playingId, setPlayingId] = useState<string | null>(null);
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>();
const listQuery = useQuery({
queryKey: qk.playlists(),
@@ -483,58 +501,52 @@ export default function Playlists() {
const removeRef = useRef(removeItem);
removeRef.current = removeItem;
async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return;
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring.
await api.moveToPlaylist(selectedId, video.id, target.id);
notify({
level: "success",
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
});
} catch {
// api.req already surfaced the reason; the refetch below puts the row back.
}
refreshAll();
}
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
// write something the user never saw.
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
const from = items.findIndex((v) => v.id === video.id);
if (from < 0) return;
const target =
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
if (target === from || target < 0 || target >= items.length) return;
order.set(arrayMove(items, from, target)); // onApply persists
}
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) {
try {
await api.renamePlaylist(selectedId, name);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
refreshAll();
}
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
}
async function deletePlaylist() {
if (selectedId == null || !detail) return;
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = linked && canWrite;
const picked = await choose({
title: t("playlists.delete"),
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
: t("playlists.confirmDelete", { name: plName(detail) }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (picked == null) return;
const onYoutube = picked === "both";
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
if (!detail) return;
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
// usePlaylistActions — the rail's context menu deletes through the SAME code.
await plActions.remove(detail);
}
function playerState(id: string, status: string) {
@@ -574,6 +586,81 @@ export default function Playlists() {
: t("playlists.dragBlockedSort");
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
const moveTargets = playlists.filter((p) => p.id !== selectedId);
const storedOrderEditable = canEdit && !viewActive;
const ctxRow = rowMenu.target;
const ctxItems: ActionMenuItem[] = !ctxRow
? []
: [
{
id: "play",
label: t("card.play"),
icon: <Play className="w-4 h-4" />,
onSelect: () => setPlayingId(ctxRow.id),
},
...(canEdit
? ([
moveTargets.length
? {
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true,
items: moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon:
pl.source === "youtube" || pl.yt_playlist_id ? (
<Youtube className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
),
onSelect: () => void moveItemTo(ctxRow, pl),
})),
}
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
// nobody why. With one playlist there is nowhere to move to, and the title says so.
{
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true,
disabled: true,
title: t("playlists.moveNoTargets"),
onSelect: () => {},
},
...(
[
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
] as const
).map(([to, label, icon], i) => ({
id: `order-${to}`,
label,
icon,
separatorBefore: i === 0,
// Same gate as the drag handle, and it explains itself the same way instead of
// silently doing nothing.
disabled: !storedOrderEditable,
title: storedOrderEditable ? undefined : dragHint,
onSelect: () => moveInOrder(ctxRow, to),
})),
{
id: "remove",
label: t("playlists.removeItem"),
icon: <X className="w-4 h-4" />,
separatorBefore: true,
danger: true,
onSelect: () => void removeItem(ctxRow),
},
] satisfies ActionMenuItem[])
: []),
];
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
}
@@ -830,6 +917,7 @@ export default function Playlists() {
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
@@ -844,6 +932,9 @@ export default function Playlists() {
</DndContext>
)}
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
{playingIndex >= 0 && (
<Suspense fallback={null}>
<PlayerModal
+122 -32
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
@@ -11,20 +11,28 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type PanelItem } from "./ui/PanelGroups";
import SortControl from "./ui/SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
type PlSort = {
key: "custom" | "name" | "count" | "duration";
key: "custom" | "name" | "count" | "duration" | "recent";
dir: "asc" | "desc";
dirtyFirst: boolean;
};
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
// becomes) — the row must not resize the moment you rename it.
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
@@ -53,12 +61,23 @@ export default function PlaylistsRail({
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
// default: a rail that reshuffles itself while you look at it costs more than it saves.
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
];
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const plActions = usePlaylistActions();
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
const railMenu = useContextMenu<Playlist>();
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
// context menu is that the action lands where the gesture did.
const [renamingId, setRenamingId] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
@@ -96,6 +115,8 @@ export default function PlaylistsRail({
return sign * (a.item_count - b.item_count);
case "duration":
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
case "recent":
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
default:
return 0;
}
@@ -142,6 +163,45 @@ export default function PlaylistsRail({
},
});
async function commitRename(pl: Playlist) {
const name = renameValue.trim();
setRenamingId(null);
if (name && name !== pl.name) await plActions.rename(pl.id, name);
}
const ctxPl = railMenu.target;
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
// stay visible and disabled so the menu doesn't change shape from list to list.
const ctxBuiltin = ctxPl?.kind === "watch_later";
const lockedTitle = ctxBuiltin ? t("playlists.builtinLocked") : undefined;
const railItems: ActionMenuItem[] = !ctxPl
? []
: [
{
id: "rename",
label: t("playlists.rename"),
icon: <Pencil className="w-4 h-4" />,
disabled: ctxBuiltin,
title: lockedTitle,
onSelect: () => {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
},
{
id: "delete",
label: t("playlists.delete"),
icon: <Trash2 className="w-4 h-4" />,
separatorBefore: true,
danger: true,
disabled: ctxBuiltin,
title: lockedTitle,
// The same dialog (and the same "what gets selected next" rule) as the detail header's
// trash button — one implementation, two entry points.
onSelect: () => void plActions.remove(ctxPl),
},
];
const items: PanelItem[] = [
{
id: "manage",
@@ -216,40 +276,70 @@ export default function PlaylistsRail({
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{visiblePlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
{visiblePlaylists.map((pl) =>
renamingId === pl.id ? (
// The row becomes its own rename field. A <button> can't contain an <input>, so the
// whole row swaps rather than the label alone — but it KEEPS the cover, because
// that is what holds the row at its two-line height. Without it the rail jumps by
// ~12px the moment you start renaming, and every row below it moves under the
// pointer.
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(pl);
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") setRenamingId(null);
}}
aria-label={t("playlists.rename")}
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>
</button>
))}
) : (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
</div>
</button>
),
)}
</div>
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
</>
),
},
+153 -26
View File
@@ -1,19 +1,166 @@
import { useState, type ReactNode } from "react";
import { MoreHorizontal } from "lucide-react";
import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
//
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the
// user has to learn both.
export interface ActionMenuItem {
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
* `ContextMenu`. One constant so a context menu can never look like a different component. */
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
const ROW =
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50";
interface MenuItemBase {
id: string;
label: string;
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
onSelect: () => void;
disabled?: boolean;
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
separatorBefore?: boolean;
}
interface ActionItem extends MenuItemBase {
onSelect: () => void;
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
danger?: boolean;
items?: never;
}
interface SubmenuItem extends MenuItemBase {
/** Non-empty → this row opens a submenu instead of firing. */
items: readonly ActionMenuItem[];
onSelect?: never;
}
// A discriminated union rather than "onSelect and items are both optional": the invariant is
// "a row either fires or it opens a submenu, never both and never neither", and this codebase's
// house rule is that such a rule lives in the type, not in a comment above it.
export type ActionMenuItem = ActionItem | SubmenuItem;
const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items);
/** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a
* `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`.
* `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */
export function MenuItemList({
items,
onClose,
}: {
items: readonly ActionMenuItem[];
onClose: () => void;
}) {
return (
<>
{items.map((it, i) => (
<Fragment key={it.id}>
{it.separatorBefore && i > 0 && (
<div role="separator" className="my-1 h-px bg-border" aria-hidden="true" />
)}
{isSubmenu(it) ? (
<SubMenu item={it} onClose={onClose} />
) : (
<PopoverItem
role="menuitem"
disabled={it.disabled}
label={it.label}
title={it.title}
onSelect={() => {
it.onSelect();
onClose();
}}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{it.icon && (
<span className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}`}>
{it.icon}
</span>
)}
<span className="flex-1 truncate">{it.label}</span>
</PopoverItem>
)}
</Fragment>
))}
</>
);
}
/** One row that opens a nested menu. It is a real `PopoverItem`, so the parent's roving focus and
* typeahead reach it; the nested popover anchors to that same button.
*
* Why it nests correctly with no extra wiring: this component renders INSIDE the parent's
* `PopoverSurface`, i.e. inside its `<FloatingNode>`, so `useFloatingNodeId()` picks the parent up
* as its tree parent. That is what makes Floating UI's outside-press tree-aware — a press inside
* the submenu is not "outside" the parent, and Escape closes the topmost layer only. */
function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void }) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: "right-start",
offset: 2,
// A mouse expects the submenu to fall open under the pointer; a finger has no hover and taps.
hoverAlso: true,
// Target lists can be long (every playlist you own), so shrink to the viewport instead of
// running off the bottom at 768px.
fitHeight: true,
});
return (
<>
<PopoverItem
elementRef={pop.refs.setReference}
{...pop.getReferenceProps()}
role="menuitem"
disabled={item.disabled}
label={item.label}
title={item.title}
aria-haspopup="menu"
aria-expanded={open}
onKeyDown={(e) => {
// The native-menu keys. Enter/Space already open it via useClick; ArrowRight is the one
// the click handler doesn't cover.
if (e.key === "ArrowRight") {
e.preventDefault();
setOpen(true);
}
}}
className={ROW}
>
{item.icon && <span className="shrink-0 text-muted">{item.icon}</span>}
<span className="flex-1 truncate">{item.label}</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
</PopoverItem>
<PopoverSurface
popover={pop}
ariaLabel={item.label}
className={`${MENU_SURFACE} overflow-y-auto`}
style={{ maxHeight: "min(24rem, var(--fit-max-h))" }}
>
{/* ArrowLeft steps back out to the parent row — Escape would close the whole stack, and a
submenu you can only leave by dismissing everything is not a submenu. Focus returns to
the trigger via the surface's own returnFocus. */}
<div
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
setOpen(false);
}
}}
>
<MenuItemList items={item.items} onClose={onClose} />
</div>
</PopoverSurface>
</>
);
}
export default function ActionMenu({
@@ -50,28 +197,8 @@ export default function ActionMenu({
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
<PopoverSurface
popover={pop}
ariaLabel={ariaLabel}
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{items.map((it) => (
<PopoverItem
key={it.id}
role="menuitem"
disabled={it.disabled}
label={it.label}
title={it.title}
onSelect={() => {
it.onSelect();
setOpen(false);
}}
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
>
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
<span className="flex-1 truncate">{it.label}</span>
</PopoverItem>
))}
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
<MenuItemList items={items} onClose={() => setOpen(false)} />
</PopoverSurface>
</>
);
+23 -3
View File
@@ -103,6 +103,11 @@ export interface AnchoredPopoverOptions {
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
hostsInput?: boolean;
/** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom —
* a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
* hair before the click handler toggles it shut again. */
hoverAlso?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -194,9 +199,14 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// a click on the input must place the caret, not close the just-opened list.
const click = useClick(context, { enabled: !isHover && !isCombobox });
const hover = useHover(context, {
enabled: isHover,
enabled: isHover || !!opts.hoverAlso,
move: false,
delay: opts.hoverDelay ?? { open: 120, close: 140 },
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
mouseOnly: !isHover,
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
// menu, so the "did they mean it" question is mostly answered.
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
handleClose: safePolygon(),
});
const focus = useFocus(context, { enabled: isHover });
@@ -339,6 +349,7 @@ export function PopoverItem({
keepSelectedPaint,
disabled,
className,
elementRef,
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
label,
...rest
@@ -353,6 +364,10 @@ export function PopoverItem({
keepSelectedPaint?: boolean;
disabled?: boolean;
className?: string;
/** Hand the item's DOM node to the caller, ON TOP of the list registration this component owns.
* The submenu idiom needs it: the nested popover anchors to the very menuitem that opens it, and
* aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */
elementRef?: (node: HTMLButtonElement | null) => void;
label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext);
@@ -364,6 +379,11 @@ export function PopoverItem({
const generatedId = useId();
const textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel });
// Both refs, in that order: the registry first (it owns roving focus), then the caller's.
const setNode = (node: HTMLButtonElement | null) => {
ref(node);
elementRef?.(node);
};
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
// follows the pointer when hovering, else the keyboard (highlightIndex = hoverIndex ?? activeIndex).
const focusActive = ctx?.activeIndex === index;
@@ -409,7 +429,7 @@ export function PopoverItem({
return (
<button
ref={ref}
ref={setNode}
type="button"
role={role}
{...handlers}
@@ -0,0 +1,243 @@
// @vitest-environment jsdom
//
// Contract of the right-click / long-press context menu (E10 S2). Same floating-ui recipe as
// ActionMenu.dom.test.tsx: stub the two observers autoUpdate wires and wrap in a FloatingTree, and
// query with `{ hidden: true }` because floating-ui never positions in jsdom, so the surface stays
// `visibility: hidden` and the a11y tree treats its contents as hidden.
//
// What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu:
// the left button keeps the row's own action, and the gesture that opens it doesn't also fire that
// action underneath.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { type ActionMenuItem } from "./ActionMenu";
import { ContextMenu } from "./ContextMenu";
import { useContextMenu } from "./useContextMenu";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
// jsdom ships no PointerEvent, so fireEvent.pointerDown would deliver pointerType/clientX/clientY
// as null — and the long-press path is ENTIRELY about those three. Without this polyfill the touch
// tests would pass for the wrong reason (every press looks like an untyped press at 0,0) and the
// "a mouse never starts the timer" guard could rot unnoticed. MouseEvent already carries the
// coordinates; only pointerType has to be added.
class PointerEventStub extends MouseEvent {
pointerType: string;
constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) {
super(type, init);
this.pointerType = init.pointerType ?? "";
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
});
afterEach(cleanup);
/** A two-row list wired exactly the way a real consumer wires it: one menu, `triggerProps` per row. */
function List({
build,
onRowClick = () => {},
}: {
build: (row: string) => ActionMenuItem[];
onRowClick?: (row: string) => void;
}) {
const menu = useContextMenu<string>();
return (
<>
{["alpha", "beta"].map((row) => (
<button key={row} onClick={() => onRowClick(row)} {...menu.triggerProps(row)}>
{row}
</button>
))}
<ContextMenu
menu={menu}
ariaLabel="Row actions"
items={menu.target ? build(menu.target) : []}
/>
</>
);
}
const renderList = (build: (row: string) => ActionMenuItem[], onRowClick = vi.fn()) => {
render(
<FloatingTree>
<List build={build} onRowClick={onRowClick} />
</FloatingTree>,
);
return { onRowClick };
};
const row = (name: string) => screen.getByRole("button", { name });
const menuitems = () =>
screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
// The surface is visibility:hidden in jsdom (floating-ui never positions), and an accessible NAME is
// empty inside a hidden subtree — so items are matched on their text, as in ActionMenu.dom.test.tsx.
const item = (label: string) => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${menuitems().join(", ")}]`);
return found as HTMLButtonElement;
};
describe("useContextMenu / ContextMenu", () => {
it("stays closed until a right-click, and builds the menu from the row that got it", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
expect(menuitems()).toEqual([]);
fireEvent.contextMenu(row("beta"), { clientX: 40, clientY: 80 });
expect(menuitems()).toEqual(["Play beta"]);
});
it("leaves the LEFT button to the row's own action", () => {
// The whole point of the sprint: right-click is a new input path, not a replacement for the
// row's default action.
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
fireEvent.click(row("alpha"));
expect(onRowClick).toHaveBeenCalledWith("alpha");
expect(menuitems()).toEqual([]);
});
it("selecting an item fires it and closes the menu", () => {
const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Remove"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]);
});
it("Shift+F10 opens it from the keyboard", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
fireEvent.keyDown(row("alpha"), { key: "F10", shiftKey: true });
expect(menuitems()).toEqual(["Play alpha"]);
});
it("Escape closes it", () => {
renderList(() => [{ id: "a", label: "Remove", onSelect: vi.fn() }]);
fireEvent.contextMenu(row("alpha"));
fireEvent.keyDown(document, { key: "Escape" });
expect(menuitems()).toEqual([]);
});
describe("long press (touch)", () => {
const press = (el: HTMLElement, x = 10, y = 10) =>
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
it("opens after the hold and SWALLOWS the click that follows the release", () => {
vi.useFakeTimers();
try {
const { onRowClick } = renderList((r) => [
{ id: "a", label: `Play ${r}`, onSelect: vi.fn() },
]);
press(row("alpha"));
act(() => void vi.advanceTimersByTime(600));
expect(menuitems()).toEqual(["Play alpha"]);
// Without the click guard the row would ALSO play the video under the open menu.
fireEvent.click(row("alpha"));
expect(onRowClick).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("a scroll (pointer moved past the slop) cancels it", () => {
vi.useFakeTimers();
try {
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
press(row("alpha"), 10, 10);
fireEvent.pointerMove(row("alpha"), { pointerType: "touch", clientX: 10, clientY: 60 });
act(() => void vi.advanceTimersByTime(600));
expect(menuitems()).toEqual([]);
// …and a cancelled long press leaves the row's own click intact.
fireEvent.click(row("alpha"));
expect(onRowClick).toHaveBeenCalledWith("alpha");
} finally {
vi.useRealTimers();
}
});
it("a MOUSE press never starts the timer (it has a right button)", () => {
vi.useFakeTimers();
try {
renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
fireEvent.pointerDown(row("alpha"), { pointerType: "mouse", clientX: 10, clientY: 10 });
act(() => void vi.advanceTimersByTime(600));
expect(menuitems()).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe("submenus", () => {
const withSub = (): ActionMenuItem[] => [
{ id: "play", label: "Play", onSelect: vi.fn() },
{
id: "move",
label: "Move to",
items: [
{ id: "t1", label: "Watch later", onSelect: vi.fn() },
{ id: "t2", label: "Homelab", onSelect: vi.fn() },
],
},
];
it("marks the submenu row as such and keeps its items unmounted until opened", () => {
renderList(withSub);
fireEvent.contextMenu(row("alpha"));
const trigger = item("Move to");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(menuitems()).toEqual(["Play", "Move to"]);
});
it("opens on click and on ArrowRight, and ArrowLeft steps back out of it", () => {
renderList(withSub);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to"));
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
// ArrowLeft is pressed INSIDE the submenu (that is where focus goes when it opens) and closes
// only that layer — Escape would close the whole stack, which is not what "back" means.
fireEvent.keyDown(item("Watch later"), { key: "ArrowLeft" });
expect(menuitems()).toEqual(["Play", "Move to"]);
fireEvent.keyDown(item("Move to"), { key: "ArrowRight" });
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
});
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => {
const chosen = vi.fn();
renderList(() => [
{ id: "play", label: "Play", onSelect: vi.fn() },
{ id: "move", label: "Move to", items: [{ id: "t1", label: "Homelab", onSelect: chosen }] },
]);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to"));
fireEvent.click(item("Homelab"));
expect(chosen).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]);
});
});
it("a disabled item is inert and never fires", () => {
const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Move to", disabled: true, onSelect }]);
fireEvent.contextMenu(row("alpha"));
const disabled = item("Move to");
expect(disabled.disabled).toBe(true);
fireEvent.click(disabled);
expect(onSelect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,26 @@
import { MENU_SURFACE, MenuItemList, type ActionMenuItem } from "./ActionMenu";
import { PopoverSurface } from "./AnchoredPopover";
import { type ContextMenuController } from "./useContextMenu";
/** The menu surface for a `useContextMenu` controller. Render ONE per list, outside the rows. */
export function ContextMenu<T>({
menu,
ariaLabel,
items,
}: {
menu: ContextMenuController<T>;
/** Accessible name for the panel — a context menu has no visible trigger to take one from. */
ariaLabel: string;
items: readonly ActionMenuItem[];
}) {
return (
<PopoverSurface
popover={menu.popover}
ariaLabel={ariaLabel}
className={`${MENU_SURFACE} overflow-y-auto`}
style={{ maxHeight: "min(28rem, var(--fit-max-h))" }}
>
<MenuItemList items={items} onClose={menu.close} />
</PopoverSurface>
);
}
@@ -0,0 +1,155 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useAnchoredPopover } from "./AnchoredPopover";
// E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they
// belong to. The surface itself is `<ContextMenu>` (ContextMenu.tsx); they are two files only so
// that the component file exports a component and nothing else (react-refresh).
//
// This is an INPUT PATH onto the menu the app already has rather
// than a second menu component. `ActionMenu` (the ⋯ overflow menu) and this one render the same
// `ActionMenuItem[]` through the same `MenuItemList`, on the same `.glass-menu` surface, so a row
// cannot grow a right-click menu that looks or behaves unlike its own ⋯ menu.
//
// Three ways in, because a menu that only opens one way does not exist on the device that lacks it:
// - right-click anywhere on the row (the desktop browser, and the reason this sprint exists),
// - long-press (touch/pen only) — the tablet has no right button,
// - Shift+F10 / the Menu key, which is free once the row is in the tab order.
// The left button is untouched: it keeps the row's own default action (select / play).
//
// ONE menu per list, not one per row. The popover is anchored to a VIRTUAL element at the pointer
// (native-menu behaviour: it opens where you clicked, not at the row's corner), so a table of 200
// rows mounts a single closed popover and `target` says which row it belongs to. Mounting one per
// row would build ~200 floating contexts to keep 199 of them shut.
/** Long-press timing. 500 ms is the platform convention on both Android and iOS; below ~400 ms it
* fires while the user is still deciding whether to scroll. */
const LONG_PRESS_MS = 500;
/** Movement (px) that reclassifies a press as a scroll and cancels the pending menu. */
const LONG_PRESS_SLOP = 10;
type Rect = {
x: number;
y: number;
width: number;
height: number;
top: number;
right: number;
bottom: number;
left: number;
};
const pointRect = (x: number, y: number): Rect => ({
x,
y,
width: 0,
height: 0,
top: y,
right: x,
bottom: y,
left: x,
});
export interface ContextMenuController<T> {
/** The row the open menu belongs to; `null` while closed. Build the items from it. */
target: T | null;
open: boolean;
close: () => void;
/** Spread on the element that should answer a right-click — the whole row, not a handle. */
triggerProps: (payload: T) => Record<string, unknown>;
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
popover: ReturnType<typeof useAnchoredPopover>;
}
export function useContextMenu<T>(): ContextMenuController<T> {
const [target, setTarget] = useState<T | null>(null);
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: (next) => {
setOpen(next);
// Drop the target on close so a stale row can't drive a re-open's first paint.
if (!next) setTarget(null);
},
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
// way near an edge.
placement: "bottom-start",
offset: 0,
fitHeight: true,
});
const { setPositionReference } = pop.refs;
const openAt = useCallback(
(x: number, y: number, payload: T, contextElement?: Element) => {
setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement });
setTarget(payload);
setOpen(true);
},
[setPositionReference],
);
// Long-press bookkeeping. Refs, not state: none of it paints, and a re-render per pointermove
// during a scroll would be a real cost on the device this path exists for.
const timer = useRef<number | null>(null);
const startedAt = useRef<{ x: number; y: number } | null>(null);
// A long-press is followed by a click on release. Without this the menu would open and the row's
// own default action (play / select) would fire underneath it.
const swallowClick = useRef(false);
const cancelPress = useCallback(() => {
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = null;
startedAt.current = null;
}, []);
const triggerProps = useCallback(
(payload: T) => ({
// Carries the `-webkit-touch-callout: none` rule (index.css) so an iOS long-press opens THIS
// menu instead of Safari's own link/image callout on top of it.
"data-context-menu-target": "",
onContextMenu: (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
// A row inside another context-menu target (a table inside a panel) claims the event.
e.stopPropagation();
openAt(e.clientX, e.clientY, payload, e.currentTarget);
},
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
// Mouse has a right button; only touch/pen need the timer.
if (e.pointerType === "mouse") return;
const { clientX: x, clientY: y } = e;
const el = e.currentTarget; // read now: currentTarget is null by the time the timer fires
startedAt.current = { x, y };
timer.current = window.setTimeout(() => {
timer.current = null;
swallowClick.current = true;
openAt(x, y, payload, el);
}, LONG_PRESS_MS);
},
onPointerMove: (e: ReactPointerEvent<HTMLElement>) => {
const from = startedAt.current;
if (!from || timer.current === null) return;
if (
Math.abs(e.clientX - from.x) > LONG_PRESS_SLOP ||
Math.abs(e.clientY - from.y) > LONG_PRESS_SLOP
)
cancelPress(); // they are scrolling, not pressing
},
onPointerUp: cancelPress,
onPointerCancel: cancelPress,
onClickCapture: (e: React.MouseEvent<HTMLElement>) => {
if (!swallowClick.current) return;
swallowClick.current = false;
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
// The keyboard route every desktop menu has. Anchored to the row's bottom-left rather than a
// pointer that isn't there.
if (e.key !== "ContextMenu" && !(e.key === "F10" && e.shiftKey)) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
openAt(r.left, r.bottom, payload, e.currentTarget);
},
}),
[openAt, cancelPress],
);
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
}
@@ -0,0 +1,86 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { qk } from "../lib/queryKeys";
import { api, HttpError, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useChoice } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
// The two playlist-level mutations that now have TWO entry points each: the detail header's
// pencil/trash buttons, and (E10 S2) the rail row's context menu. They live here rather than in
// either caller because the delete is not one API call — it is a dialog whose SHAPE depends on
// whether the list is linked to YouTube, plus the "what gets selected next" dance — and this
// codebase's recurring bug is the second copy of such a flow drifting from the first.
/** The subset of a playlist these actions need — satisfied by both `Playlist` (the rail's rows)
* and `PlaylistDetail` (the detail pane), which is why neither is named here. */
type Deletable = Pick<Playlist, "id" | "name" | "kind" | "yt_playlist_id">;
export function usePlaylistActions() {
const qc = useQueryClient();
const { t } = useTranslation();
const choose = useChoice();
const canWrite = useMe().can_write;
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
async function rename(id: number, name: string): Promise<void> {
const next = name.trim();
if (!next) return;
try {
await api.renamePlaylist(id, next);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(id) });
}
async function remove(pl: Deletable): Promise<void> {
if (pl.kind === "watch_later") return; // the built-in list has no delete anywhere in the UI
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = !!pl.yt_playlist_id && canWrite;
const name = playlistName(pl, t);
const picked = await choose({
title: t("playlists.delete"),
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name })
: t("playlists.confirmDelete", { name }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (picked == null) return;
const onYoutube = picked === "both";
// Pick the next selection from what's left *now* (don't go via null, or the rail's auto-select
// effect would re-pick the just-deleted id from the stale list). Only when we deleted the list
// the user is looking at — deleting some other row from its context menu must not move them.
if (selectedId === pl.id) {
const listed = qc.getQueryData<Playlist[]>(qk.playlists()) ?? [];
setSelectedId(listed.find((p) => p.id !== pl.id)?.id ?? null);
}
try {
await api.deletePlaylist(pl.id, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(pl.id) });
qc.invalidateQueries({ queryKey: qk.playlists() });
}
return { rename, remove };
}
@@ -65,10 +65,26 @@
"railSortName": "Name",
"railSortCount": "Item count",
"railSortDuration": "Total length",
"railSortRecent": "Recently changed",
"dirtyFirst": "Unsynced first",
"colTitle": "Title",
"colChannel": "Channel",
"colDuration": "Length",
"rowMenu": "Video actions",
"moveTo": "Move to another list",
"moveNoTargets": "There is no other playlist to move it to yet.",
"movedToast": "“{{title}}” moved to “{{name}}” ✓",
"moveFailed": "Couldn't move the video.",
"moveErrors": {
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
"same_playlist": "That video is already in this playlist."
},
"orderTop": "Move to top",
"orderUp": "Move up",
"orderDown": "Move down",
"orderBottom": "Move to bottom",
"railMenu": "Playlist actions",
"builtinLocked": "The built-in Watch later list can't be renamed or deleted.",
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"confirmRemove": "Remove “{{title}}” from this playlist?",
@@ -65,10 +65,26 @@
"railSortName": "Név",
"railSortCount": "Elemszám",
"railSortDuration": "Összhossz",
"railSortRecent": "Utoljára módosított",
"dirtyFirst": "Nem szinkronizáltak elöl",
"colTitle": "Cím",
"colChannel": "Csatorna",
"colDuration": "Hossz",
"rowMenu": "Videó műveletek",
"moveTo": "Áthelyezés másik listába",
"moveNoTargets": "Még nincs másik lejátszási lista, ahová áthelyezhetnéd.",
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
"moveFailed": "Nem sikerült áthelyezni a videót.",
"moveErrors": {
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
"same_playlist": "Ez a videó már ebben a listában van."
},
"orderTop": "Legfelülre",
"orderUp": "Eggyel feljebb",
"orderDown": "Eggyel lejjebb",
"orderBottom": "Legalulra",
"railMenu": "Lejátszási lista műveletei",
"builtinLocked": "A beépített Watch later lista nem nevezhető át és nem törölhető.",
"removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról",
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
+16
View File
@@ -559,6 +559,22 @@ body {
min-height: 2.75rem;
}
}
/* ===== E10 S2 · rows that answer a right-click / long-press (useContextMenu) =====
The long-press path competes with two browser behaviours that fire at the same gesture:
iOS Safari's own callout (Copy / Share over a link or image) and the text-selection handles.
Both would appear ON TOP of our menu, so the attribute the hook stamps on the row turns them
off. Deliberately NOT `user-select: none` on a precise pointer — selecting a video title with
the mouse is legitimate, and only the touch path has the conflict. */
[data-context-menu-target] {
-webkit-touch-callout: none;
}
@media (pointer: coarse) {
[data-context-menu-target] {
-webkit-user-select: none;
user-select: none;
}
}
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);