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:
2026-09-26 04:05:39 +02:00
parent aaf1843b09
commit 7455fff401
24 changed files with 1061 additions and 425 deletions
+59 -69
View File
@@ -2,7 +2,7 @@ import { useCallback, 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, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
@@ -26,6 +26,7 @@ import PlaylistLimitsHint from "./PlaylistLimitsHint";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { MoreButton } from "./ui/MoreButton";
type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent";
@@ -199,43 +200,17 @@ export default function PlaylistsRail({
}
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";
// Interpolated, not spelled out: the HU copy said "Watch later", a name the Hungarian UI never
// shows for this list ("Megnézem később") — so the message named a list the reader had never seen
// (E10 review round 1). Passing the localized name makes the two impossible to drift.
const lockedNote = ctxBuiltin
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
: undefined;
// The list's ONE menu (usePlaylistActions.items), the same the page header's "…" opens; the rail
// renames IN the row that was right-clicked — the action lands where the gesture did.
const railItems: ActionMenuItem[] = !ctxPl
? []
: [
{
id: "rename",
label: t("playlists.rename"),
icon: <Pencil className="w-4 h-4" />,
disabled: ctxBuiltin,
note: lockedNote,
onSelect: () => {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
: plActions.items(ctxPl, {
onRename: () => {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
...comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
{
id: "delete",
label: t("playlists.delete"),
icon: <Trash2 className="w-4 h-4" />,
separatorBefore: true,
danger: true,
disabled: ctxBuiltin,
note: lockedNote,
// 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),
},
];
pin: comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
});
const items: PanelItem[] = [
{
@@ -350,44 +325,59 @@ export default function PlaylistsRail({
/>
</div>
) : (
<Pressable
key={pl.id}
ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
// Back from the rename field by Enter/Escape — see refocusRowRef.
if (el && refocusRowRef.current === pl.id) {
refocusRowRef.current = null;
el.focus();
}
}}
onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)}
layer={pl.id !== selectedId}
className={`${ROW_BASE} ${
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
}`}
>
<div className="w-8.5 h-8.5 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"}`}
// `group`: the row's "…" shows on its hover or focus (`.reveal`), and always on touch.
// A sibling of the row button, not inside it — a button cannot hold a button.
<div key={pl.id} className="group relative" {...railMenu.triggerProps(pl)}>
<Pressable
ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
// Back from the rename field by Enter/Escape — see refocusRowRef.
if (el && refocusRowRef.current === pl.id) {
refocusRowRef.current = null;
el.focus();
}
}}
onClick={() => setSelectedId(pl.id)}
layer={pl.id !== selectedId}
// pr-9: the "…" sits over the row's right end, so the name truncates short of it.
className={`${ROW_BASE} pr-9 ${
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
}`}
>
<div className="w-8.5 h-8.5 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 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>
</div>
</Pressable>
</Pressable>
<MoreButton
label={t("playlists.railMenu")}
expanded={railMenu.open && railMenu.target === pl}
menuProps={railMenu.buttonProps(pl)}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 ${
railMenu.open && railMenu.target === pl ? "" : "reveal"
}`}
/>
</div>
),
)}
</div>