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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user