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:
@@ -1,9 +1,9 @@
|
||||
import { Play, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "../lib/tableColumns";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
@@ -18,16 +18,15 @@ export function playlistColumns({
|
||||
t,
|
||||
indexOf,
|
||||
onPlay,
|
||||
onRemove,
|
||||
canEdit,
|
||||
more,
|
||||
}: {
|
||||
t: TFunction;
|
||||
/** Position in the STORED order (1-based on screen) — not the row's position in the current view,
|
||||
* so a view-sort never renumbers the playlist under the reader. */
|
||||
indexOf: (v: Video) => number;
|
||||
onPlay: (v: Video) => void;
|
||||
onRemove: (v: Video) => void;
|
||||
canEdit: boolean;
|
||||
/** The row's "…" — the visible door to its menu (E28 C1), where "Remove" now lives. */
|
||||
more: (v: Video) => ReactNode;
|
||||
}): Column<Video>[] {
|
||||
const cols: Column<Video>[] = [
|
||||
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
|
||||
@@ -38,6 +37,8 @@ export function playlistColumns({
|
||||
width: "4.5rem",
|
||||
nowrap: true,
|
||||
hideInCard: true,
|
||||
// The position number is the first thing a phone row can spare; the order still shows.
|
||||
phoneHidden: true,
|
||||
// Sorting by "#" ascending is the stored order itself — that is what makes it a truthful
|
||||
// default for the header arrow, and a one-click way back from any other sort.
|
||||
sortable: true,
|
||||
@@ -61,12 +62,14 @@ export function playlistColumns({
|
||||
<Pressable
|
||||
onClick={() => onPlay(v)}
|
||||
aria-label={`${t("card.play")} — ${v.title}`}
|
||||
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group touch-target"
|
||||
// A NAMED group: the row is a `group` too (its "…" reveals on the row's hover), and a
|
||||
// plain group-hover here would light the play overlay whenever the row is hovered.
|
||||
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target"
|
||||
>
|
||||
{v.thumbnail_url && (
|
||||
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition">
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100 transition">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</Pressable>
|
||||
@@ -85,6 +88,7 @@ export function playlistColumns({
|
||||
key: "channel",
|
||||
header: t("playlists.colChannel"),
|
||||
width: "12rem",
|
||||
phoneHidden: true,
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (v) => v.channel_title ?? "",
|
||||
@@ -123,25 +127,17 @@ export function playlistColumns({
|
||||
},
|
||||
];
|
||||
|
||||
if (canEdit) {
|
||||
cols.push({
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => onRemove(v)}
|
||||
label={t("playlists.removeItemOf", { title: v.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
),
|
||||
});
|
||||
}
|
||||
cols.push({
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
// Room for the "…" at 44px on touch (`.touch-target`), not just its 28px under a mouse.
|
||||
width: "3.25rem",
|
||||
nowrap: true,
|
||||
// The X that stood here went into the menu with the Delete key and an undo toast (E28 C1): a
|
||||
// one-click destructive button on every row, for an action the toast now makes reversible.
|
||||
render: more,
|
||||
});
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user