Files
siftlode/frontend/src/modules/playlists/playlistColumns.tsx
T
peter 8cf8c4139c feat(playlists): select rows with a box, Shift for a range, x and Esc
A box first in every row and one over the column for the rows shown, the
selected row tinted. A filter keeps what it hides selected; another list
starts over; a row that left the list drops out. Watch later has none.

A box no longer counts as a typing target: a click on one left the focus
there and the page's keys went dead.
2026-11-01 20:49:59 +01:00

215 lines
8.3 KiB
TypeScript

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 { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
// 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,
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;
/** The row's "…" — the visible door to its menu, 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 },
{
key: "pos",
header: "#",
align: "right",
width: "4.5rem",
nowrap: true,
hideInCard: true,
// The position number is the first thing a phone row can spare; the order still shows.
narrowHidden: 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,
sortValue: indexOf,
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
},
{
key: "title",
header: t("playlists.colTitle"),
// The flexible column: under `table-fixed` (see Playlists.tsx) width:100% hands it every
// pixel the fixed columns don't claim. Auto layout could not do this — a `truncate` cell is
// `white-space: nowrap`, so its MIN-content width is the whole title, and the column grew
// until Length and the remove button hung off the page scroller.
width: "100%",
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">
<Pressable
onClick={() => onPlay(v)}
aria-label={`${t("card.play")} — ${v.title}`}
// 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 && (
<RemoteImage src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
{/* Narrow, the Length column is gone and its value rides on the picture, as on the
media card (the owner's pick A). */}
<span className="pointer-events-none absolute right-0.5 bottom-0.5 @2xl:hidden">
{lengthCell(v, t, true)}
</span>
<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>
<Pressable
selectable
onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
className="min-h-6 min-w-0 flex-1 touch-target"
>
{/* Two lines and the channel under them while the Channel column is hidden (the owner,
2026-09-26: at 375 the one-line title kept ~40px); one line beside the columns. */}
<span className="break-words text-[0.8125rem] leading-snug text-fg line-clamp-2 @2xl:line-clamp-1">
{v.title}
</span>
{v.channel_title && (
<span className="block truncate text-2xs text-muted @2xl:hidden">
{v.channel_title}
</span>
)}
</Pressable>
</div>
),
},
{
key: "channel",
header: t("playlists.colChannel"),
width: "12rem",
narrowHidden: true,
nowrap: true,
sortable: true,
sortValue: (v) => v.channel_title ?? "",
filter: { kind: "text", get: (v) => v.channel_title ?? "" },
render: (v) => (
<span className="text-[0.8125rem] text-muted truncate block">{v.channel_title}</span>
),
},
{
key: "duration",
header: t("playlists.colDuration"),
align: "right",
width: "5rem",
// Narrow, the length rides on the picture instead (the title cell's badge).
narrowHidden: true,
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) => lengthCell(v, t),
},
];
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: a
// one-click destructive button on every row, for an action the toast now makes reversible.
render: more,
});
return cols;
}
/** The box column, first in the row, for a list whose rows can be selected. A separate column from
* `playlistColumns`, whose array the view's filter and sort read: a tick must not re-run them. */
export function selectColumn({
t,
state,
onToggleAll,
isSelected,
onToggle,
}: {
t: TFunction;
state: "none" | "some" | "all";
onToggleAll: () => void;
isSelected: (id: string) => boolean;
onToggle: (id: string, range: boolean) => void;
}): Column<Video> {
const allLabel = t("playlists.selectAllShown");
return {
key: "select",
header: allLabel,
headerCell: (
<RowSelectBox
label={allLabel}
title={allLabel}
checked={state === "all"}
mixed={state === "some"}
onToggle={onToggleAll}
/>
),
width: "2.25rem",
hideInCard: true,
render: (v) => (
<RowSelectBox
label={t("playlists.selectRow", { title: v.title })}
checked={isSelected(v.id)}
onToggle={(shift) => onToggle(v.id, shift)}
/>
),
};
}
// A video's length, or its live / upcoming state when it has none. `onPicture`: the dark badge on the
// thumbnail (the narrow row), readable on any frame, like the media card's length badge.
function lengthCell(v: Video, t: TFunction, onPicture = false): ReactNode {
if (v.duration_seconds != null)
return onPicture ? (
<span className="block rounded-sm bg-black/80 px-1 text-3xs leading-4 font-medium text-white tabular-nums">
{formatDuration(v.duration_seconds)}
</span>
) : (
<span className="text-2xs text-muted tabular-nums">{formatDuration(v.duration_seconds)}</span>
);
if (v.live_status !== "live" && v.live_status !== "upcoming") return null;
return (
<span
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-live text-live-fg" /* the broadcast red, not --danger */
: onPicture
? "bg-black/80 text-white"
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${v.live_status}`)}
</span>
);
}