feat(playlists): hybrid table over the drag list, and guard its destructive paths
The column sort and the filters are a VIEW; the playlist's stored order only changes when the user presses "Save this order", which still goes through the undoable path. Picking a sort used to write straight to the server, so merely looking at the list by title rewrote the playlist and marked a YouTube-linked mirror dirty. The table: - playlistColumns.tsx holds the column defs; the body renders them as table rows and fills the grip cell itself, since the handle needs the row's sortable listeners that a render closure can't reach. - The # column shows the STORED position, so a view-sort never renumbers the playlist under the reader. - Saving is withheld while a filter is active (it would drop the hidden items), and drag is disabled under any view, with the reason spelled out rather than silently doing nothing. - applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the channel grouping compose, and getting that wrong produces a plausible order that the save button would then persist. - dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder", but only a pointer could move it. The guards: - Delete is ONE dialog with every outcome. It was two chained confirms where the second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist. ConfirmProvider gained useChoice() for it. - ConfirmProvider focuses Cancel when an offered action is destructive; the confirm button took autoFocus unconditionally, so a stray Enter on an open dialog ran "Delete user" or "Clear audit log". - Removing an item asks first, and rebases the undo history instead of throwing it away: useUndoable.rebase maps every snapshot, so the reorder history stays valid for the remaining items. - A failed detail query showed "Loading…" forever; it now says so and offers a retry. Rename and reorder failures are surfaced instead of swallowed (reorder was a bare .then(), which also produced an unhandled rejection). - Local reorder/remove no longer require YouTube write scope. - playlistName() replaces the watch-later localizer copied into three files.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { Play, X } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "./tableHeader";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
//
|
||||
// The `grip` column carries no renderer: the drag handle needs the `useSortable` attributes that
|
||||
// only exist inside the row component, which a `render(row)` closure can't reach. It stays in the
|
||||
// list anyway so ONE column array drives both the header and the body — the row renderer fills
|
||||
// that cell itself. Everything else is an ordinary cell.
|
||||
export const GRIP_KEY = "grip";
|
||||
|
||||
export function playlistColumns({
|
||||
t,
|
||||
indexOf,
|
||||
onPlay,
|
||||
onRemove,
|
||||
canEdit,
|
||||
}: {
|
||||
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;
|
||||
}): Column<Video>[] {
|
||||
const cols: Column<Video>[] = [
|
||||
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
|
||||
{
|
||||
key: "pos",
|
||||
header: "#",
|
||||
align: "right",
|
||||
width: "3rem",
|
||||
nowrap: true,
|
||||
hideInCard: true,
|
||||
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
header: t("playlists.colTitle"),
|
||||
sortable: true,
|
||||
sortValue: (v) => v.title ?? "",
|
||||
filter: { kind: "text", get: (v) => v.title ?? "" },
|
||||
cardPrimary: true,
|
||||
render: (v) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<button
|
||||
onClick={() => onPlay(v)}
|
||||
aria-label={`${t("card.play")} — ${v.title}`}
|
||||
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
||||
>
|
||||
{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">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
<button onClick={() => onPlay(v)} className="min-w-0 flex-1 text-left">
|
||||
<span className="block text-[13px] text-fg truncate max-w-[34rem]">{v.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: t("playlists.colChannel"),
|
||||
sortable: true,
|
||||
sortValue: (v) => v.channel_title ?? "",
|
||||
filter: { kind: "text", get: (v) => v.channel_title ?? "" },
|
||||
render: (v) => (
|
||||
<span className="text-[13px] text-muted truncate block max-w-[14rem]">{v.channel_title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: t("playlists.colDuration"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
// Nulls (live/upcoming, or not yet enriched) sort last in BOTH directions in the old
|
||||
// comparator; sortRows is a plain numeric compare, so park them at the end of an ascending
|
||||
// sort with a large sentinel rather than letting them read as zero-length.
|
||||
sortValue: (v) => v.duration_seconds ?? Number.MAX_SAFE_INTEGER,
|
||||
render: (v) =>
|
||||
v.duration_seconds != null ? (
|
||||
<span className="text-[11px] text-muted tabular-nums">
|
||||
{formatDuration(v.duration_seconds)}
|
||||
</span>
|
||||
) : v.live_status === "live" || v.live_status === "upcoming" ? (
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
v.live_status === "live"
|
||||
? "bg-red-500/90 text-white"
|
||||
: "bg-card border border-border text-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`card.${v.live_status}`)}
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
if (canEdit) {
|
||||
cols.push({
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<button
|
||||
onClick={() => onRemove(v)}
|
||||
title={t("playlists.removeItem")}
|
||||
aria-label={t("playlists.removeItemOf", { title: v.title })}
|
||||
className="text-muted hover:text-fg p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}
|
||||
Reference in New Issue
Block a user