From 5d1065c1bcfab6c9899486c6e2feaec893d7416d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 15:04:47 +0200 Subject: [PATCH] feat(playlists): a playlist's menu copies its name and its YouTube address Every object's action list ends its middle group with Copy name / Copy link (E28 C1, the rows a finger cannot select); the playlist's had neither. --- .../PlaylistsRail.rename.dom.test.tsx | 1 + .../usePlaylistActions.dom.test.tsx | 25 ++++++++++++++++--- .../src/components/usePlaylistActions.tsx | 14 ++++++----- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/PlaylistsRail.rename.dom.test.tsx b/frontend/src/components/PlaylistsRail.rename.dom.test.tsx index 46b34c6c..cec4806e 100644 --- a/frontend/src/components/PlaylistsRail.rename.dom.test.tsx +++ b/frontend/src/components/PlaylistsRail.rename.dom.test.tsx @@ -146,6 +146,7 @@ describe("PlaylistsRail · the row's '…'", () => { await vi.waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([ "playlists.rename", + "common.copy.name", "playlists.exportToYoutube", "playlists.delete", ]), diff --git a/frontend/src/components/usePlaylistActions.dom.test.tsx b/frontend/src/components/usePlaylistActions.dom.test.tsx index d3b66bd8..6ff5cf38 100644 --- a/frontend/src/components/usePlaylistActions.dom.test.tsx +++ b/frontend/src/components/usePlaylistActions.dom.test.tsx @@ -102,7 +102,16 @@ describe("usePlaylistActions — a playlist's menu", () => { it("lists a YouTube-linked list with unpushed edits in full", () => { const items = rows({ ...linked, dirty: true }); - expect(ids(items)).toEqual(["rename", "pin", "open-youtube", "push", "revert", "delete"]); + expect(ids(items)).toEqual([ + "rename", + "copy-text", + "copy-link", + "pin", + "open-youtube", + "push", + "revert", + "delete", + ]); expect(items.find((i) => i.id === "push")!.label).toBe("playlists.pushToYoutube"); // One separator opens the YouTube group, one the delete. expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([ @@ -113,7 +122,7 @@ describe("usePlaylistActions — a playlist's menu", () => { it("offers a local list an export and nothing to open or revert", () => { const items = rows({ id: 5, name: "Mine", kind: "user", yt_playlist_id: null, dirty: false }); - expect(ids(items)).toEqual(["rename", "pin", "push", "delete"]); + expect(ids(items)).toEqual(["rename", "copy-text", "pin", "push", "delete"]); expect(items.find((i) => i.id === "push")!.label).toBe("playlists.exportToYoutube"); }); @@ -121,6 +130,8 @@ describe("usePlaylistActions — a playlist's menu", () => { h.canWrite = false; expect(ids(rows({ ...linked, dirty: false }))).toEqual([ "rename", + "copy-text", + "copy-link", "pin", "open-youtube", "delete", @@ -135,7 +146,7 @@ describe("usePlaylistActions — a playlist's menu", () => { yt_playlist_id: null, dirty: false, }); - expect(ids(items)).toEqual(["rename", "pin", "delete"]); + expect(ids(items)).toEqual(["rename", "copy-text", "pin", "delete"]); for (const id of ["rename", "delete"]) { const it = items.find((i) => i.id === id)!; expect(it.disabled).toBe(true); @@ -143,6 +154,14 @@ describe("usePlaylistActions — a playlist's menu", () => { } }); + // E28 review L6: every object's menu copies its name and link (the finger cannot select them); + // the playlist's did not. + it("copies the list's name, and a linked list's YouTube address", () => { + const items = rows(linked); + expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name"); + expect(items.find((i) => i.id === "copy-link")!.label).toBe("common.copy.link"); + }); + it("opens the list on YouTube inside the click, in a new tab", () => { const open = vi.spyOn(window, "open").mockReturnValue(null); const row = rows(linked).find((i) => i.id === "open-youtube")!; diff --git a/frontend/src/components/usePlaylistActions.tsx b/frontend/src/components/usePlaylistActions.tsx index c9e4632f..80f0fbc8 100644 --- a/frontend/src/components/usePlaylistActions.tsx +++ b/frontend/src/components/usePlaylistActions.tsx @@ -12,6 +12,7 @@ import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; import { useMe } from "../lib/useMe"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { createStore } from "../lib/store"; +import { useCopyItems } from "./copyItems"; // A playlist's ONE action list (E28 C1) and the mutations behind it. Two entry points open it — the // rail row (right-click, long-press, its "…") and the list page header's "…" — so it lives here, @@ -39,6 +40,7 @@ export function usePlaylistActions() { const choose = useChoice(); const confirm = useConfirm(); const canWrite = useMe().can_write; + const copy = useCopyItems(); const { selectedId } = usePlaylists(); const { setSelectedId } = usePlaylistsActions(); const { pushing, reverting } = inFlight.use(); @@ -227,6 +229,9 @@ export function usePlaylistActions() { ? t("playlists.builtinLocked", { name: t("playlists.watchLater") }) : undefined; const busy = (id: number | null) => id === pl.id; + const ytUrl = pl.yt_playlist_id + ? `https://www.youtube.com/playlist?list=${pl.yt_playlist_id}` + : null; const youtube: ActionMenuItem[] = [ ...(pl.yt_playlist_id ? [ @@ -236,12 +241,7 @@ export function usePlaylistActions() { icon: , // A new tab only opens inside the click (a popup blocker refuses one a task later). inGesture: true, - onSelect: () => - void window.open( - `https://www.youtube.com/playlist?list=${pl.yt_playlist_id}`, - "_blank", - "noopener,noreferrer", - ), + onSelect: () => void window.open(ytUrl!, "_blank", "noopener,noreferrer"), }, ] : []), @@ -284,6 +284,8 @@ export function usePlaylistActions() { note: lockedNote, onSelect: onRename, }, + // Its name and, when linked, its YouTube address — every object's menu copies them (E28 L6). + ...copy(playlistName(pl, t), ytUrl, "name"), ...pin, ...youtube.map((it, i) => ({ ...it, separatorBefore: i === 0 })), {