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
+230 -69
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
@@ -21,10 +21,11 @@ import {
Play,
Rows3,
Trash2,
X,
} from "lucide-react";
import { api, HttpError, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { navItemAttrs, useListNav } from "../lib/listNav";
import { toggleWatchState } from "../lib/plexWatch";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./confirmContext";
@@ -32,6 +33,12 @@ import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useCopyItems } from "./copyItems";
import { usePlexCardActions } from "./usePlexCardActions";
import { Pressable } from "./ui/Pressable";
import { DragHandle } from "./ui/DragHandle";
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
@@ -51,9 +58,22 @@ import { PageHeading } from "./ui/Headings";
// list. Reorder is drag & drop (a whole show-group moves as one block; episodes reorder within their
// show). Two layouts — Accordion (A) and Tree (C) — are a per-account preference. Rendered as a
// PlexBrowse subview; "Play all" opens the player with the whole ordered list as its queue.
//
// E28 C1: every row — a title, a show group, a season — has ONE menu, opened by its "…" (shown on
// hover or focus, always on touch), a right-click, a long-press or Shift+F10. Removing needs no
// confirm: the toast's Undo puts the rows back where they stood. `j`/`k` walk the visible rows and
// Delete takes out the one the keyboard is on; the playlist's own rename and delete sit in the
// header's "…".
type Layout = "accordion" | "tree";
type Season = { key: string; number: number | null; items: PlexCard[] };
type ShowBlock = Extract<Block, { kind: "show" }>;
/** What a row's menu acts on. `key` is the row's identity across renders (the objects are rebuilt
* on every one), so "is THIS row's menu the open one" compares keys. */
type MenuTarget =
| { kind: "item"; key: string; item: PlexCard }
| { kind: "show"; key: string; block: ShowBlock }
| { kind: "season"; key: string; block: ShowBlock; season: Season };
type Block =
| { kind: "movie"; id: string; item: PlexCard }
| {
@@ -120,7 +140,11 @@ type Ctx = {
toggleShow: (k: string) => void;
toggleSeason: (k: string) => void;
onPlay: (rk: string) => void;
onRemove: (keys: string[]) => void;
/** The row's "…" and its right-click / long-press / Shift+F10 — the page's one menu, on `target`. */
more: (target: MenuTarget) => ReactNode;
trigger: (target: MenuTarget) => Record<string, unknown>;
/** The row's index among the rows the keyboard walks (`navItemAttrs`). */
navIndex: (key: string) => number;
};
function Poster({ src, className }: { src: string; className: string }) {
@@ -145,11 +169,14 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
opacity: isDragging ? 0.6 : 1,
};
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const target: MenuTarget = { kind: "item", key: ep.id, item: ep };
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(ep.id), ep.title)}
className={`group flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: ep.title })}
@@ -161,7 +188,8 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
</span>
<Pressable
onClick={() => ctx.onPlay(ep.id)}
className="group flex min-w-0 flex-1 items-center gap-2.5"
data-nav-open
className="group/thumb flex min-w-0 flex-1 items-center gap-2.5"
>
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
@@ -173,20 +201,13 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
</span>
)}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100">
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/thumb:opacity-100">
<Play className="h-4 w-4 text-white" fill="currentColor" />
</div>
</div>
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
</Pressable>
<IconButton
variant="danger"
onClick={() => ctx.onRemove([ep.id])}
disabled={ctx.busy}
label={t("plex.playlist.removeItem", { title: ep.title })}
>
<X />
</IconButton>
{ctx.more(target)}
</div>
);
}
@@ -203,8 +224,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
opacity: isDragging ? 0.6 : 1,
};
const dense = ctx.layout === "tree";
const target: MenuTarget = { kind: "item", key: item.id, item };
return (
<div ref={setNodeRef} style={style} className={`${SURFACE.card} flex items-center gap-3 p-2`}>
<div
ref={setNodeRef}
style={style}
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(item.id), item.title)}
className={`group ${SURFACE.card} flex items-center gap-3 p-2`}
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: item.title })}
{...attributes}
@@ -212,30 +240,33 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
/>
<Pressable
onClick={() => ctx.onPlay(item.id)}
className="group flex min-w-0 flex-1 items-center gap-3"
data-nav-open
className="group/thumb flex min-w-0 flex-1 items-center gap-3"
>
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted">{item.year}</div>
</div>
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover:bg-accent/15 group-hover:text-accent">
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover/thumb:bg-accent/15 group-hover/thumb:text-accent">
<Play className="h-4 w-4" fill="currentColor" />
</div>
</Pressable>
<IconButton
variant="danger"
onClick={() => ctx.onRemove([item.id])}
disabled={ctx.busy}
label={t("plex.playlist.removeItem", { title: item.title })}
>
<X />
</IconButton>
{ctx.more(target)}
</div>
);
}
function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ctx: Ctx }) {
/** A season's name, as its label and in the toast that says it went. */
function useSeasonLabel() {
const { t } = useTranslation();
return (s: Season) =>
s.number != null
? t("plex.playlist.season", { n: s.number })
: t("plex.playlist.seasonUnknown");
}
function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: block.id,
@@ -248,15 +279,17 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
};
const open = ctx.expandedShows.has(block.showKey);
const dense = ctx.layout === "tree";
const seasonLabel = (s: Season) =>
s.number != null
? t("plex.playlist.season", { n: s.number })
: t("plex.playlist.seasonUnknown");
const seasonLabel = useSeasonLabel();
const target: MenuTarget = { kind: "show", key: block.id, block };
return (
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
<div className="flex items-center gap-2.5 p-2">
{/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */}
<div
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(block.id), block.title)}
className="group flex items-center gap-2.5 p-2"
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: block.title })}
{...attributes}
@@ -290,20 +323,17 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
))}
</Pressable>
)}
<Pressable onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1">
<Pressable
onClick={() => ctx.toggleShow(block.showKey)}
data-nav-open
className="min-w-0 flex-1"
>
<div className="truncate text-sm font-semibold">{block.title}</div>
<div className="text-2xs text-muted">
{t("plex.playlist.episodes", { count: block.items.length })}
</div>
</Pressable>
<IconButton
variant="danger"
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
label={t("plex.playlist.removeShow")}
>
<X />
</IconButton>
{ctx.more(target)}
</div>
{open && (
@@ -315,9 +345,13 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
{block.seasons.map((s) => {
const sKey = `${block.showKey}:${s.key}`;
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
const sTarget: MenuTarget = { kind: "season", key: sKey, block, season: s };
return (
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
<div className="flex items-center gap-1.5 py-1 pl-1">
<div
{...ctx.trigger(sTarget)}
className="group flex items-center gap-1.5 py-1 pl-1"
>
{dense && (
<IconButton
onClick={() => ctx.toggleSeason(sKey)}
@@ -330,15 +364,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<span className="text-2xs font-medium uppercase tracking-wide text-muted">
{seasonLabel(s)} · {s.items.length}
</span>
<IconButton
variant="danger"
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
label={t("plex.playlist.removeSeason")}
className="ml-1"
>
<X />
</IconButton>
<span className="ml-1">{ctx.more(sTarget)}</span>
</div>
{sOpen && (
<div
@@ -365,10 +391,13 @@ export default function PlexPlaylistView({
playlistId,
onBack,
onPlay,
onInfoCard,
}: {
playlistId: number;
onBack: () => void;
onPlay: (itemRk: string, queue: string[]) => void;
/** A title's media-info page, from its menu's "Details". */
onInfoCard?: (card: PlexCard) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -390,7 +419,8 @@ export default function PlexPlaylistView({
// The query, the optimistic copy and the two writes that edit it (reorder, remove) live in
// `usePlexPlaylistEdits`, where their failure paths can be driven directly: the drag that reaches
// `commit` cannot be, since dnd-kit's sensors do not activate under jsdom.
const { q, items, busy, invalidate, commit, onRemove } = usePlexPlaylistEdits(playlistId);
const { q, items, busy, invalidate, commit, onRemove, setStatus } =
usePlexPlaylistEdits(playlistId);
const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]);
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
@@ -504,6 +534,105 @@ export default function PlexPlaylistView({
}
}
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
const rowMenu = useContextMenu<MenuTarget>();
const copy = useCopyItems();
const seasonLabel = useSeasonLabel();
const titleActions = usePlexCardActions(
{
onOpen: (c) => onPlay(c.id, order),
onInfo: onInfoCard,
onToggleWatched: (c) =>
void toggleWatchState(c, t, {
optimistic: (next) => setStatus(c.id, next),
resync: invalidate,
}),
},
rowMenu.open,
);
// What a row's removal takes out, and the name its toast says.
const removal = (target: MenuTarget): { keys: string[]; title: string } =>
target.kind === "item"
? { keys: [target.item.id], title: target.item.title }
: target.kind === "show"
? { keys: target.block.items.map((i) => i.id), title: target.block.title }
: {
keys: target.season.items.map((i) => i.id),
title: `${target.block.title} · ${seasonLabel(target.season)}`,
};
const remove = (target: MenuTarget) => {
const { keys, title } = removal(target);
void onRemove(keys, title);
};
const removeRow = (target: MenuTarget, label: string): ActionMenuItem => ({
id: "remove",
label,
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
disabled: busy,
onSelect: () => remove(target),
});
const playRow = (label: string, first: PlexCard | undefined): ActionMenuItem[] =>
first
? [
{
id: "play",
label,
icon: <Play className="w-4 h-4" />,
onSelect: () => onPlay(first.id, order),
},
]
: [];
const menuTarget = rowMenu.target;
const menuItems: ActionMenuItem[] = !menuTarget
? []
: menuTarget.kind === "item"
? [
...titleActions.items(menuTarget.item),
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
]
: menuTarget.kind === "show"
? [
...playRow(t("plex.playlist.playShow"), menuTarget.block.items[0]),
...copy(menuTarget.block.title, null).map((it, i) => ({
...it,
separatorBefore: i === 0,
})),
removeRow(menuTarget, t("plex.playlist.removeShow")),
]
: [
...playRow(t("plex.playlist.playSeason"), menuTarget.season.items[0]),
removeRow(menuTarget, t("plex.playlist.removeSeason")),
];
// --- The keyboard's rows: every row on screen, in reading order ---------------------------------
// A title, a show's header and — while the show is open — its episodes; a season label is a
// heading, not a row (its "…" holds its actions). Delete takes out the row it is on.
const navRows: MenuTarget[] = [];
for (const b of blocks) {
if (b.kind === "movie") {
navRows.push({ kind: "item", key: b.item.id, item: b.item });
continue;
}
navRows.push({ kind: "show", key: b.id, block: b });
if (!expandedShows.has(b.showKey)) continue;
for (const se of b.seasons) {
if (layout === "tree" && collapsedSeasons.has(`${b.showKey}:${se.key}`)) continue;
for (const ep of se.items) navRows.push({ kind: "item", key: ep.id, item: ep });
}
}
const navIndexOf = new Map(navRows.map((r, i) => [r.key, i]));
const listRef = useRef<HTMLDivElement>(null);
useListNav(listRef, {
enabled: items.length > 0,
onDelete: (i) => {
const row = navRows[i];
if (row) remove(row);
},
});
const { buttonProps: rowMenuButton, triggerProps: rowMenuTrigger } = rowMenu;
const ctx: Ctx = {
layout,
busy,
@@ -512,9 +641,43 @@ export default function PlexPlaylistView({
toggleShow,
toggleSeason,
onPlay: (rk) => onPlay(rk, order),
onRemove,
more: (target) => {
const mine = rowMenu.open && rowMenu.target?.key === target.key;
return (
<MoreButton
label={t("plex.playlist.rowMenu")}
expanded={mine}
menuProps={rowMenuButton(target)}
className={mine ? undefined : "reveal"}
/>
);
},
trigger: rowMenuTrigger,
navIndex: (key) => navIndexOf.get(key) ?? -1,
};
// --- The playlist's own menu: the header's "…" ---------------------------------------------------
const headerMenu = useContextMenu<number>();
const headerItems: ActionMenuItem[] = [
{
id: "rename",
label: t("plex.playlist.rename"),
icon: <Pencil className="w-4 h-4" />,
onSelect: () => {
setName(q.data?.title ?? "");
setRenaming(true);
},
},
{
id: "delete",
label: t("plex.playlist.delete"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
onSelect: () => void del(),
},
];
const layoutOptions: ViewOption<Layout>[] = [
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
@@ -570,18 +733,6 @@ export default function PlexPlaylistView({
onChange={setLayout}
label={t("plex.playlist.layoutLabel")}
/>
<IconButton
ref={renameFocus.target}
variant="glass"
size="md"
onClick={() => {
setName(q.data?.title ?? "");
setRenaming(true);
}}
label={t("plex.playlist.rename")}
>
<Pencil />
</IconButton>
{items.length > 0 && (
<Button
onClick={() => onPlay(order[0]!, order)}
@@ -590,9 +741,16 @@ export default function PlexPlaylistView({
{t("plex.playlist.playAll")}
</Button>
)}
<IconButton variant="glass" size="md" onClick={del} label={t("plex.playlist.delete")}>
<Trash2 />
</IconButton>
{/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a
rename opened from there hands the focus back to it. */}
<MoreButton
ref={renameFocus.target}
variant="glass"
size="md"
label={t("plex.playlist.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps(playlistId)}
/>
</div>
{q.isError && !q.data ? (
@@ -611,7 +769,7 @@ export default function PlexPlaylistView({
accessibility={dndA11y}
>
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5">
<div ref={listRef} className="space-y-1.5">
{blocks.map((b) =>
b.kind === "movie" ? (
<MovieRow key={b.id} item={b.item} ctx={ctx} />
@@ -624,6 +782,9 @@ export default function PlexPlaylistView({
</DndContext>
)}
</div>
<ContextMenu menu={rowMenu} ariaLabel={t("plex.playlist.rowMenu")} items={menuItems} />
<ContextMenu menu={headerMenu} ariaLabel={t("plex.playlist.menu")} items={headerItems} />
{titleActions.panel}
</div>
);
}