From 928eac4c89480585cf4b3613b18498c4755ef700 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 19:59:56 +0200 Subject: [PATCH] feat(e10): open the app's menu with a right-click, starting with playlist rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` + `` 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. --- frontend/src/components/Playlists.tsx | 191 ++++++++++---- frontend/src/components/PlaylistsRail.tsx | 154 ++++++++--- frontend/src/components/ui/ActionMenu.tsx | 179 +++++++++++-- .../src/components/ui/AnchoredPopover.tsx | 26 +- .../components/ui/ContextMenu.dom.test.tsx | 243 ++++++++++++++++++ frontend/src/components/ui/ContextMenu.tsx | 26 ++ frontend/src/components/ui/useContextMenu.ts | 155 +++++++++++ frontend/src/components/usePlaylistActions.ts | 86 +++++++ frontend/src/i18n/locales/en/playlists.json | 16 ++ frontend/src/i18n/locales/hu/playlists.json | 16 ++ frontend/src/index.css | 16 ++ 11 files changed, 997 insertions(+), 111 deletions(-) create mode 100644 frontend/src/components/ui/ContextMenu.dom.test.tsx create mode 100644 frontend/src/components/ui/ContextMenu.tsx create mode 100644 frontend/src/components/ui/useContextMenu.ts create mode 100644 frontend/src/components/usePlaylistActions.ts diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 2dc648a0..5870281f 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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