feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)

- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
This commit is contained in:
2026-09-26 05:56:07 +02:00
parent b0e51c6f89
commit 1b946113c6
27 changed files with 1354 additions and 293 deletions
@@ -28,6 +28,9 @@ interface ChannelLayoutGridProps<T> {
aboutKey?: string;
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string;
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
* belongs beside the name it acts on, not under the card's last line. */
moreKey?: string;
rowClassName?: (row: T) => string;
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
* a channel answers the right-click in the card layout exactly as it does in the table. */
@@ -42,6 +45,7 @@ export default function ChannelLayoutGrid<T>({
leadKey,
aboutKey,
actionsKey,
moreKey,
rowClassName,
rowProps,
emptyText,
@@ -49,11 +53,12 @@ export default function ChannelLayoutGrid<T>({
const { t } = useTranslation();
const spec = CHANNEL_LAYOUT_SPEC.cards;
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
const primary = columns.filter((c) => c.cardPrimary);
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
// Everything else flows in the meta line: not the heading, not a slotted column.
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
@@ -70,6 +75,7 @@ export default function ChannelLayoutGrid<T>({
))}
</div>
{lead && <div className="shrink-0">{lead.render(row)}</div>}
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
</div>
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">