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