feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)
- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
fixed widths had left the title no width.
This commit is contained in:
@@ -24,15 +24,10 @@ import {
|
||||
ArrowUp,
|
||||
ArrowUpToLine,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
History,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
@@ -47,19 +42,16 @@ import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { useComfortPin, pinVideo } from "./comfortPin";
|
||||
import { useComfortPin, pinPlaylist, pinVideo } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./playlistsContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { ColumnHead } from "./ui/tableHeader";
|
||||
@@ -71,7 +63,8 @@ import { DragHandle } from "./ui/DragHandle";
|
||||
import { Input } from "./ui/Input";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useCopyItems } from "./copyItems";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
// body that is still a drag-reorderable @dnd-kit list.
|
||||
@@ -134,7 +127,8 @@ 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 state-layer transition-colors"
|
||||
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
||||
className="group border-b border-border/50 state-layer transition-colors"
|
||||
{...navItemAttrs(index)}
|
||||
{...menuProps}
|
||||
>
|
||||
@@ -142,7 +136,7 @@ function Row({
|
||||
<td
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
|
||||
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${col.phoneHidden ? " hidden sm:table-cell" : ""}`}
|
||||
>
|
||||
{col.key === GRIP_KEY ? (
|
||||
// The one cell the column defs can't render: the handle needs this row's sortable
|
||||
@@ -164,14 +158,11 @@ function Row({
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
// 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 { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const plActions = usePlaylistActions();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const renameFocus = useReturnFocus(renaming);
|
||||
@@ -267,6 +258,10 @@ export default function Playlists() {
|
||||
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
|
||||
// second one on the same <tr>.
|
||||
const comfortPin = useComfortPin(rowMenu.open);
|
||||
const copy = useCopyItems();
|
||||
// The header's "…": the list's own menu, the one its rail row opens (usePlaylistActions.items).
|
||||
const headerMenu = useContextMenu<PlaylistDetail>();
|
||||
const headerPin = useComfortPin(headerMenu.open);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
@@ -323,17 +318,15 @@ export default function Playlists() {
|
||||
|
||||
const builtin = detail?.kind === "watch_later";
|
||||
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
|
||||
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
|
||||
// dirty mirror so it won't clobber unpushed edits); the header's "Unsynced" says when they differ.
|
||||
const editable = !builtin;
|
||||
const linked = editable && !!detail?.yt_playlist_id;
|
||||
// LOCAL editing (reorder, remove) needs no YouTube write scope — `canWrite` gates only the
|
||||
// push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
|
||||
// LOCAL editing (reorder, remove) needs no YouTube write scope — `can_write` gates only the
|
||||
// push/delete-on-YouTube paths (usePlaylistActions). Folding it in here would have taken drag and remove away from
|
||||
// every account without the scope, which they have always had.
|
||||
const canEdit = editable;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
|
||||
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
|
||||
// than closing over it.
|
||||
@@ -350,11 +343,8 @@ export default function Playlists() {
|
||||
});
|
||||
}, []);
|
||||
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
|
||||
const onRemove = useCallback((v: Video) => {
|
||||
void removeRef.current(v);
|
||||
}, []);
|
||||
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
|
||||
// inline ✕ use — one confirm, one optimistic update, one undo-stack rebase, no second copy to
|
||||
// Delete key use — one optimistic update, one undo toast, one undo-stack rebase, no second copy to
|
||||
// drift. It answers whether the removal actually happened, because the player has to decide
|
||||
// afterwards whether to step to the next item or close. It removes from the list the player was
|
||||
// launched from (see `playing.listId`), not from whatever is selected by now.
|
||||
@@ -376,9 +366,25 @@ export default function Playlists() {
|
||||
[items],
|
||||
);
|
||||
|
||||
// The row's "…" opens the row menu under itself (E28 C1). Which row's menu is open is part of the
|
||||
// cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
|
||||
const { buttonProps: rowMenuButton, open: rowMenuOpen, target: rowMenuTarget } = rowMenu;
|
||||
const columns = useMemo(
|
||||
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
|
||||
[t, storedIndex, onPlay, onRemove, canEdit],
|
||||
() =>
|
||||
playlistColumns({
|
||||
t,
|
||||
indexOf: storedIndex,
|
||||
onPlay,
|
||||
more: (v) => (
|
||||
<MoreButton
|
||||
label={t("playlists.rowMenu")}
|
||||
expanded={rowMenuOpen && rowMenuTarget === v}
|
||||
menuProps={rowMenuButton(v)}
|
||||
className={rowMenuOpen && rowMenuTarget === v ? undefined : "reveal"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[t, storedIndex, onPlay, rowMenuButton, rowMenuOpen, rowMenuTarget],
|
||||
);
|
||||
|
||||
const filterActive = Object.values(filters).some(isActive);
|
||||
@@ -437,108 +443,6 @@ export default function Playlists() {
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
}
|
||||
|
||||
async function revertToYoutube() {
|
||||
if (selectedId == null || !detail || reverting) return;
|
||||
const ok = await confirm({
|
||||
title: t("playlists.revertTitle"),
|
||||
message: t("playlists.revertMsg"),
|
||||
confirmLabel: t("playlists.revertConfirm"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setReverting(true);
|
||||
try {
|
||||
await api.revertPlaylist(selectedId);
|
||||
refreshAll();
|
||||
notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.revertFailed"));
|
||||
} finally {
|
||||
setReverting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToYoutube() {
|
||||
if (selectedId == null || !detail || pushing) return;
|
||||
setPushing(true);
|
||||
try {
|
||||
const plan = await api.playlistPushPlan(selectedId);
|
||||
const nothingToDo =
|
||||
plan.action === "update" &&
|
||||
!plan.add_yt &&
|
||||
!plan.add_local &&
|
||||
!plan.remove_yt &&
|
||||
!plan.remove_local &&
|
||||
!plan.to_reorder &&
|
||||
!plan.rename;
|
||||
if (nothingToDo) {
|
||||
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
|
||||
return;
|
||||
}
|
||||
if (!plan.affordable) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushNoQuota", {
|
||||
left: plan.remaining_today,
|
||||
units: plan.units_estimate,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let message: string;
|
||||
if (plan.action === "create") {
|
||||
message = t("playlists.pushPlanCreate", {
|
||||
count: plan.add_yt,
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
});
|
||||
} else {
|
||||
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||||
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||||
const here: string[] = [];
|
||||
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||||
if (plan.remove_local)
|
||||
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||||
const yt: string[] = [];
|
||||
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
|
||||
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
|
||||
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
|
||||
if (plan.rename) yt.push(t("playlists.mergeRename"));
|
||||
const sentences = [...here, ...yt];
|
||||
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
|
||||
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
|
||||
const quota =
|
||||
plan.units_estimate > 0
|
||||
? t("playlists.mergeQuota", {
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
})
|
||||
: t("playlists.mergeQuotaFree");
|
||||
message = sentences.join(" ") + quota;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: t("playlists.pushTitle"),
|
||||
message,
|
||||
confirmLabel: t("playlists.pushConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
const r = await api.pushPlaylist(selectedId);
|
||||
refreshAll();
|
||||
if (r.failures.length) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushPartial", { count: r.failures.length }),
|
||||
});
|
||||
} else {
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
||||
}
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.pushFailed"));
|
||||
} finally {
|
||||
setPushing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active: a, over } = e;
|
||||
if (!over || a.id === over.id || selectedId == null) return;
|
||||
@@ -548,26 +452,20 @@ export default function Playlists() {
|
||||
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||
}
|
||||
|
||||
/** @returns whether the item was actually removed — the player needs to know, because it must
|
||||
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (E28 C1:
|
||||
* the X column became the Delete key + this toast; no confirm, because nothing is lost).
|
||||
* @returns whether the item was actually removed — the player needs to know, because it must
|
||||
* step to the next item (or close) only when the removal really happened. */
|
||||
async function removeItem(
|
||||
video: Video,
|
||||
listId: number | null = selectedRef.current,
|
||||
): Promise<boolean> {
|
||||
if (listId == null) return false;
|
||||
const ok = await confirm({
|
||||
title: t("playlists.removeItem"),
|
||||
message: t("playlists.confirmRemove", { title: video.title }),
|
||||
confirmLabel: t("playlists.removeItem"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return false;
|
||||
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
|
||||
// be removing from the list it was launched from while another one is selected. Read after the
|
||||
// confirm: the selection may have changed while it was open.
|
||||
// be removing from the list it was launched from while another one is selected.
|
||||
if (listId === selectedRef.current) {
|
||||
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
|
||||
// drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
// A removal isn't itself on the reorder undo stack, but it no longer THROWS AWAY that history:
|
||||
// rebasing drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = order.value.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
@@ -578,17 +476,19 @@ export default function Playlists() {
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
// Where it stood in the STORED order — what the Undo puts it back at (the server's list, so a
|
||||
// view sort on screen does not decide it).
|
||||
const at = before?.items.findIndex((v) => v.id === video.id) ?? -1;
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
try {
|
||||
await api.removeFromPlaylist(listId, video.id);
|
||||
refreshAll(listId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
|
||||
// full-screen player there is not even a row snapping back to show it) — unless `req` already
|
||||
// put it in its error dialog, which `notifyActionError` checks.
|
||||
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
|
||||
// there is not even a row snapping back to show it) — unless `req` already put it in its error
|
||||
// dialog, which `notifyActionError` checks.
|
||||
notifyActionError(e, t("playlists.removeFailed"));
|
||||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
||||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
||||
@@ -597,6 +497,24 @@ export default function Playlists() {
|
||||
refreshAll(listId);
|
||||
return false;
|
||||
}
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.removedToast", { title: video.title }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
// ONE request that puts it back at its index (the server renumbers around it): an append
|
||||
// plus a whole-list reorder would land it at the end whenever the second call failed.
|
||||
onClick: () =>
|
||||
void api
|
||||
.addToPlaylist(listId, video.id, at < 0 ? undefined : at)
|
||||
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
|
||||
.finally(() => {
|
||||
refreshAll(listId);
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||||
}),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
|
||||
const removeRef = useRef(removeItem);
|
||||
@@ -659,13 +577,6 @@ export default function Playlists() {
|
||||
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
|
||||
}
|
||||
|
||||
async function deletePlaylist() {
|
||||
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) {
|
||||
api
|
||||
.setState(id, status)
|
||||
@@ -729,6 +640,10 @@ export default function Playlists() {
|
||||
onSelect: () => openPlayer(ctxRow.id),
|
||||
},
|
||||
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
|
||||
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
|
||||
...it,
|
||||
separatorBefore: i === 0,
|
||||
})),
|
||||
...(canEdit
|
||||
? ([
|
||||
...(recentMoveTarget
|
||||
@@ -802,6 +717,17 @@ export default function Playlists() {
|
||||
: []),
|
||||
];
|
||||
|
||||
const headerPl = headerMenu.target;
|
||||
const headerItems: ActionMenuItem[] = !headerPl
|
||||
? []
|
||||
: plActions.items(headerPl, {
|
||||
onRename: () => {
|
||||
setRenameValue(headerPl.name);
|
||||
setRenaming(true);
|
||||
},
|
||||
pin: headerPin.item(pinPlaylist(headerPl.id), plName(headerPl)),
|
||||
});
|
||||
|
||||
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
|
||||
// another list under an open player (the command palette works over it) puts the page into
|
||||
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
|
||||
@@ -894,32 +820,7 @@ export default function Playlists() {
|
||||
aria-label={t("playlists.rename")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<IconButton
|
||||
ref={renameFocus.target}
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
label={t("playlists.rename")}
|
||||
>
|
||||
<Pencil />
|
||||
</IconButton>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<IconLink
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
)}
|
||||
</>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
@@ -939,39 +840,15 @@ export default function Playlists() {
|
||||
>
|
||||
{t("playlists.playAll")}
|
||||
</Button>
|
||||
{editable && canWrite && (
|
||||
<IconButton
|
||||
variant={detail.dirty ? "outline-accent" : "outline"}
|
||||
onClick={pushToYoutube}
|
||||
loading={pushing}
|
||||
label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
>
|
||||
{linked ? <Youtube /> : <Upload />}
|
||||
</IconButton>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<IconButton
|
||||
label={t("playlists.revert")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</IconButton>
|
||||
)}
|
||||
{editable && (
|
||||
<IconButton
|
||||
label={t("playlists.delete")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
onClick={deletePlaylist}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
|
||||
menu (E28 C1), the same the rail row opens — the header keeps Play all. */}
|
||||
<MoreButton
|
||||
ref={renameFocus.target}
|
||||
variant="outline"
|
||||
label={t("playlists.railMenu")}
|
||||
expanded={headerMenu.open}
|
||||
menuProps={headerMenu.buttonProps(detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1074,7 +951,18 @@ export default function Playlists() {
|
||||
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
|
||||
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
|
||||
dnd-kit has handled). */}
|
||||
<NavGrid as="tbody">
|
||||
<NavGrid
|
||||
as="tbody"
|
||||
// Delete on a walked row = the menu's Remove (E28 C1), with the same undo toast.
|
||||
onDelete={
|
||||
canEdit
|
||||
? (i) => {
|
||||
const v = shownRef.current[i];
|
||||
if (v) void removeItem(v);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{shown.map((v, i) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
@@ -1100,6 +988,7 @@ export default function Playlists() {
|
||||
|
||||
{/* 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} />
|
||||
<ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} />
|
||||
</div>
|
||||
{player}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user