diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 80ebce67..682388d3 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -34,6 +34,7 @@ import type { FeedView } from "../lib/feedView"; import { channelYouTubeUrl, formatViews } from "../lib/format"; import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; +import { Button } from "./ui/Button"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it @@ -441,21 +442,18 @@ export default function ChannelPage({ it) so a large channel's paced tail reads as visible progress rather than a stuck spinner. */} {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( - + )} {!me.is_demo && ( + ) : ( - + ))} {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s otherwise), and personal tags are for your subscriptions — so only offer it on a diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index b649feda..41e58e19 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -9,7 +9,8 @@ import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; import { api, type DownloadJob, type ShareLink } from "../lib/api"; -import { inputCls, btnCls } from "./ui/form"; +import { inputCls } from "./ui/form"; +import { Button } from "./ui/Button"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; // --- share with a registered user (ACL) ----------------------------------------------------- @@ -343,31 +344,29 @@ function LinkShare({ job }: { job: DownloadJob }) { autoComplete="new-password" />
- - +
) : ( - + {t("downloads.share.newLink")} + )} ); diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index b23de353..bab983f6 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -2,22 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Crop, - Eye, - EyeOff, - Pause, - Play, - Scissors, - SplitSquareHorizontal, - Loader2, - X, -} from "lucide-react"; +import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react"; import clsx from "clsx"; import Modal from "./Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { inputCls, btnCls } from "./ui/form"; +import { Button } from "./ui/Button"; // mm:ss.d timecode <-> seconds. function tc(s: number): string { @@ -621,28 +612,23 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {reencode ? t("editor.willReencode") : t("editor.willCopy")}
- - +
diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx new file mode 100644 index 00000000..ec414b66 --- /dev/null +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +// +// S5: the shared ); + expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button"); + }); + + it("applies the variant paint recipe", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "Delete" }); + expect(btn.className).toContain("btn-danger"); + expect(btn.className).toContain("py-1.5"); + }); + + it("exposes an accessible name for an icon-only button while dropping the text", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "Block channel" }); + expect(btn.getAttribute("title")).toBe("Block channel"); + expect(btn.textContent).not.toContain("this text is dropped"); + expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); + }); + + it("loading disables the button, marks aria-busy, and blocks clicks", () => { + const onClick = vi.fn(); + render( + , + ); + const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + expect(btn.getAttribute("aria-busy")).toBe("true"); + // the leading icon is swapped for a spinner + expect(btn.querySelector("[data-testid=ic]")).toBeNull(); + fireEvent.click(btn); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("fullWidth adds the w-full utility", () => { + render(); + expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full"); + }); +}); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 00000000..024adca5 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,100 @@ +import { forwardRef } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import clsx from "clsx"; +import { Loader2 } from "lucide-react"; + +// The ONE action-button primitive. Before this there were 385 raw + ); +});