feat(design): a library row keeps Share (the link copy) in sight, ahead of save (E28 S3e3)
The owner's call: sharing a download's link is the library's main errand, more important than saving the file, so it does not hide in the row's "…". Manage sharing stays in the menu. The "…" tests press as a mouse, so a stray long-press timer no longer fires after teardown.
This commit is contained in:
+4
-3
@@ -289,8 +289,9 @@ Which actions are visible and which live in a menu.
|
||||
(a title's is its `usePlexCardActions` list plus the removal); removals take the same undo toast,
|
||||
`j`/`k` walk the visible rows and Delete removes the walked one; its rename and delete sit in the
|
||||
header's "…". A toast that carries an action lives at least 8 s, whatever the toast setting. A
|
||||
download row keeps play (pause / resume / retry in the queue) and the save link, and ends in its
|
||||
"…" (`useDownloadActions`: the editors, sharing, re-download, the copies, cancel / delete / remove
|
||||
download row keeps play (pause / resume / retry in the queue) and the save link, a library row also
|
||||
Share (the link copy, ahead of save), and ends in its "…" (`useDownloadActions`: the editors,
|
||||
managing the shares, re-download, the copies, cancel / delete / remove
|
||||
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
||||
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
||||
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
|
||||
@@ -436,6 +437,6 @@ Wherever there is text, the user can select it and copy it, and no click action
|
||||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; on a phone the table drops "#" and Channel so the title keeps a width |
|
||||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, save, edit, details, re-download, share, manage sharing, copies, cancel / delete / remove); a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's six rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("DownloadCenter · the queue row", () => {
|
||||
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
|
||||
// it must not open it again.
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Clip 1"));
|
||||
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Clip 1"));
|
||||
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
|
||||
});
|
||||
@@ -159,7 +159,7 @@ describe("DownloadCenter · the queue row", () => {
|
||||
target: { value: "x" },
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Clip 1"));
|
||||
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Clip 1"));
|
||||
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
|
||||
});
|
||||
@@ -191,23 +191,24 @@ describe("DownloadCenter · the queue row", () => {
|
||||
});
|
||||
|
||||
describe("DownloadCenter · the library row", () => {
|
||||
it("shows play, save and its '…'; the editors, sharing and delete are in the menu", async () => {
|
||||
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
|
||||
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowButtons("Clip 2")).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.rowMenu",
|
||||
]);
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"downloads.actions.editDetails",
|
||||
"downloads.actions.redownload",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.shareManage",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
@@ -215,6 +216,21 @@ describe("DownloadCenter · the library row", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("copies the share link from the row's own Share button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||
const create = vi
|
||||
.spyOn(api, "createDownloadLink")
|
||||
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
fireEvent.click(
|
||||
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
|
||||
);
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves the file through the same link as the row's save button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
const followed: string[] = [];
|
||||
@@ -243,8 +259,8 @@ describe("DownloadCenter · the library row", () => {
|
||||
await screen.findByText("Clip 3");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.shareManage",
|
||||
"common.copy.title",
|
||||
"downloads.actions.delete",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Settings2,
|
||||
Share2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./ui/Tabs";
|
||||
@@ -259,6 +260,10 @@ export default function DownloadCenter() {
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
|
||||
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{saveBtn(job)}
|
||||
</DownloadRow>
|
||||
))}
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("NotificationsPanel · a server row", () => {
|
||||
for (const name of ["Ready 1", "Activity 8"]) {
|
||||
await openMenu(() => fireEvent.click(moreOf(name)));
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf(name));
|
||||
fireEvent.pointerDown(moreOf(name), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf(name));
|
||||
fireEvent.click(moreOf(name), { detail: 1 });
|
||||
});
|
||||
@@ -168,7 +168,7 @@ describe("NotificationsPanel · a server row", () => {
|
||||
h.server = [server(1)]; // the next poll: a NEW object for the same notification
|
||||
rerender();
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Ready 1"));
|
||||
fireEvent.pointerDown(moreOf("Ready 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Ready 1"));
|
||||
fireEvent.click(moreOf("Ready 1"), { detail: 1 });
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("PlexPlaylistView · a row's menu", () => {
|
||||
await screen.findByText("Movie 1");
|
||||
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Movie 1"));
|
||||
fireEvent.pointerDown(moreOf("Movie 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Movie 1"));
|
||||
fireEvent.click(moreOf("Movie 1"), { detail: 1 });
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@ describe("a video's menu", () => {
|
||||
expect(more().getAttribute("aria-expanded")).toBe("true");
|
||||
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
|
||||
// it must not open it again.
|
||||
fireEvent.pointerDown(more());
|
||||
fireEvent.pointerDown(more(), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(more());
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
expect(labels()).toEqual([]);
|
||||
@@ -200,7 +200,7 @@ describe("a video's menu", () => {
|
||||
setup(video({}));
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
// A press on the open row's "…" that never became a click (released elsewhere)…
|
||||
fireEvent.pointerDown(more());
|
||||
fireEvent.pointerDown(more(), { pointerType: "mouse" });
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// …then Enter on the button: a click with no pointer (detail 0).
|
||||
fireEvent.click(more(), { detail: 0 });
|
||||
|
||||
@@ -28,7 +28,8 @@ import { menuGroups } from "./ui/menuGroups";
|
||||
// A download's ONE action list (E28 C1): the row's "…", its right-click, long-press and Shift+F10,
|
||||
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
|
||||
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
|
||||
// the rest — the editors, sharing, re-download, the copies and the destructive rows — lives here.
|
||||
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
|
||||
// managing the shares, re-download, the copies and the destructive rows — lives here.
|
||||
|
||||
export type DownloadTab = "queue" | "done" | "shared";
|
||||
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
|
||||
@@ -165,7 +166,19 @@ export function useDownloadActions({
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return menuGroups(
|
||||
[...(a.play ? [play(job)] : []), save(job)],
|
||||
// The row's own buttons first, in the row's order: sharing the link is the library's main
|
||||
// errand, ahead of saving the file.
|
||||
[
|
||||
...(a.play ? [play(job)] : []),
|
||||
{
|
||||
id: "share",
|
||||
label: t("downloads.actions.share"),
|
||||
icon: icon(Share2),
|
||||
disabled: sharingId !== null,
|
||||
onSelect: () => void quickShare(job),
|
||||
},
|
||||
save(job),
|
||||
],
|
||||
[
|
||||
...(a.edit ? [edit(job)] : []),
|
||||
...(a.editDetails
|
||||
@@ -196,13 +209,6 @@ export function useDownloadActions({
|
||||
: []),
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "share",
|
||||
label: t("downloads.actions.share"),
|
||||
icon: icon(Share2),
|
||||
disabled: sharingId !== null,
|
||||
onSelect: () => void quickShare(job),
|
||||
},
|
||||
{
|
||||
id: "share-manage",
|
||||
label: t("downloads.actions.shareManage"),
|
||||
@@ -261,5 +267,8 @@ export function useDownloadActions({
|
||||
/** The queue's visible primary action and the error dialog's Retry. */
|
||||
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
|
||||
|
||||
return { items, run };
|
||||
/** The library row's visible Share: the same one-click link copy as its menu row. */
|
||||
const share = (job: DownloadJob) => void quickShare(job);
|
||||
|
||||
return { items, run, share };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user