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:
@@ -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} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user