feat(ui): migrate the canonical action-button surface onto <Button>

Sweep the rounded-lg solid/ghost/outline action-button family onto the shared primitive:
ConfirmProvider, ErrorDialog, DownloadDialog, DownloadCenter, downloadModals, downloadAdmin,
ProfileEditor, PersonalViewsModal, SettingsPanel, WatchPage, DraftSaveBar. Deliberately left
on their own styles per the agreed scope: login/onboarding rounded-xl CTAs (Welcome/Onboarding/
Setup/KeyGate), glass Plex surfaces, segmented pills/toggles/switches/chips, dropdown triggers,
data-driven Banner/tone maps, text-xs dense buttons, and tiny inline p-1/p-1.5 icon controls —
none map 1:1 to a variant+size without a visual change, and the repo has no tailwind-merge.
This commit is contained in:
2026-08-28 03:12:29 +02:00
parent 4892c38466
commit 5280949667
11 changed files with 97 additions and 98 deletions
+10 -10
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Modal from "./Modal"; import Modal from "./Modal";
import { Button } from "./ui/Button";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in // App-styled, promise-based replacement for window.confirm. Wrap the tree in
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm(); // <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
@@ -100,26 +101,25 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
<div <div
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"} className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
> >
<button <Button
variant="outline"
autoFocus={anyDanger} autoFocus={anyDanger}
onClick={() => close(null)} onClick={() => close(null)}
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${ fullWidth={multi}
multi ? "w-full order-last" : "" className={multi ? "order-last" : ""}
}`}
> >
{state.opts.cancelLabel ?? t("common.cancel")} {state.opts.cancelLabel ?? t("common.cancel")}
</button> </Button>
{choices.map((c, i) => ( {choices.map((c, i) => (
<button <Button
key={c.id} key={c.id}
variant={c.danger ? "danger" : "primary"}
autoFocus={!anyDanger && i === choices.length - 1} autoFocus={!anyDanger && i === choices.length - 1}
onClick={() => close(c.id)} onClick={() => close(c.id)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${ fullWidth={multi}
multi ? "w-full" : ""
} ${c.danger ? "btn-danger" : "btn-primary"}`}
> >
{c.label || t("common.confirm")} {c.label || t("common.confirm")}
</button> </Button>
))} ))}
</div> </div>
</Modal> </Modal>
+16 -11
View File
@@ -31,6 +31,7 @@ import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals"; import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin"; import { AdminSystem } from "./downloadAdmin";
@@ -169,20 +170,24 @@ export default function DownloadCenter() {
placeholder={t("downloads.page.addUrl")} placeholder={t("downloads.page.addUrl")}
className={inputCls} className={inputCls}
/> />
<button <Button
variant="ghost"
size="sm"
iconOnly
icon={<ClipboardPaste className="w-4 h-4" />}
label={t("downloads.page.paste")}
onClick={() => pasteFromClipboard(true)} onClick={() => pasteFromClipboard(true)}
title={t("downloads.page.paste")} className="shrink-0"
aria-label={t("downloads.page.paste")} />
className="inline-flex items-center justify-center px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition shrink-0" <Button
> variant="primary"
<ClipboardPaste className="w-4 h-4" /> size="sm"
</button> icon={<Plus className="w-4 h-4" />}
<button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())} onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition shrink-0" className="shrink-0"
> >
<Plus className="w-4 h-4" /> {t("downloads.page.add")} {t("downloads.page.add")}
</button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{queue.map((job) => ( {queue.map((job) => (
+5 -11
View File
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal"; import Modal from "./Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
@@ -89,19 +90,12 @@ export default function DownloadDialog({
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <Button variant="ghost" size="sm" onClick={onClose}>
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.actions.cancel")} {t("downloads.actions.cancel")}
</button> </Button>
<button <Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
onClick={submit}
disabled={busy || !chosen}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.dialog.submit")} {t("downloads.dialog.submit")}
</button> </Button>
</div> </div>
{manage && ( {manage && (
+2 -6
View File
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog"; import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal from "./Modal"; import Modal from "./Modal";
import { Button } from "./ui/Button";
// Renders the current app error (if any) as a modal the user must acknowledge. // Renders the current app error (if any) as a modal the user must acknowledge.
export default function ErrorDialog() { export default function ErrorDialog() {
@@ -12,12 +13,7 @@ export default function ErrorDialog() {
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md"> <Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p> <p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
<div className="flex justify-end mt-5"> <div className="flex justify-end mt-5">
<button <Button onClick={dismissError}>{t("errors.ok")}</Button>
onClick={dismissError}
className="px-4 py-2 rounded-lg btn-primary text-sm font-medium transition"
>
{t("errors.ok")}
</button>
</div> </div>
</Modal> </Modal>
); );
+14 -10
View File
@@ -18,6 +18,7 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./Modal"; import Modal from "./Modal";
import { Button } from "./ui/Button";
import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters"; import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters";
@@ -202,14 +203,16 @@ export default function PersonalViewsModal() {
placeholder={t("views.namePlaceholder")} placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/> />
<button <Button
variant="primary"
size="sm"
iconOnly
icon={<BookmarkPlus className="h-4 w-4" />}
label={t("views.save")}
onClick={onCreate} onClick={onCreate}
disabled={!createName.trim()} disabled={!createName.trim()}
title={t("views.save")} className="shrink-0"
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40" />
>
<BookmarkPlus className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
@@ -272,13 +275,14 @@ export default function PersonalViewsModal() {
)} )}
</div> </div>
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3"> <div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
<button <Button
variant="primary"
size="sm"
icon={<Play className="h-3.5 w-3.5" />}
onClick={() => apply(selected)} onClick={() => apply(selected)}
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
> >
<Play className="h-3.5 w-3.5" />
{t("views.apply")} {t("views.apply")}
</button> </Button>
<button <button
onClick={async () => { onClick={async () => {
const saved = await saveCurrent(selected.id); const saved = await saveCurrent(selected.id);
+10 -14
View File
@@ -7,6 +7,7 @@ import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
@@ -145,12 +146,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div> </div>
{!formOpen ? ( {!formOpen ? (
<button <Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
onClick={startNew} {t("downloads.profiles.new")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition" </Button>
>
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
</button>
) : ( ) : (
<div className="rounded-xl border border-border p-4 space-y-3"> <div className="rounded-xl border border-border p-4 space-y-3">
<div> <div>
@@ -263,19 +261,17 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
/> />
</div> </div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-end gap-2 pt-1">
<button <Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
onClick={() => setFormOpen(false)}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.actions.cancel")} {t("downloads.actions.cancel")}
</button> </Button>
<button <Button
variant="primary"
size="sm"
onClick={() => save.mutate()} onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending} disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
> >
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")} {editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
</button> </Button>
</div> </div>
</div> </div>
)} )}
+3 -6
View File
@@ -11,6 +11,7 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { inputCls, Section, SettingRow, Switch } from "./ui/form"; import { inputCls, Section, SettingRow, Switch } from "./ui/form";
import { Button } from "./ui/Button";
import { type SaveState } from "./ui/DraftSaveBar"; import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { useWizard } from "./WizardProvider"; import { useWizard } from "./WizardProvider";
@@ -472,13 +473,9 @@ function SignInMethods({ me }: { me: Me }) {
className={inputCls} className={inputCls}
/> />
{err && <p className="text-xs text-danger">{err}</p>} {err && <p className="text-xs text-danger">{err}</p>}
<button <Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
type="submit"
disabled={busy || !next}
className="px-3 py-1.5 rounded-lg text-sm btn-primary font-medium disabled:opacity-40 transition"
>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")} {busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</button> </Button>
</form> </form>
)} )}
</div> </div>
+7 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Download, Link2, Lock, PlayCircle } from "lucide-react"; import { Download, Link2, Lock, PlayCircle } from "lucide-react";
import { Button } from "./ui/Button";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app // Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny // tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -200,13 +201,16 @@ export default function WatchPage() {
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40" className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
/> />
{error && <div className="text-sm text-danger mt-2">{error}</div>} {error && <div className="text-sm text-danger mt-2">{error}</div>}
<button <Button
variant="primary"
size="md"
fullWidth
onClick={unlock} onClick={unlock}
disabled={!password || unlocking} disabled={!password || unlocking}
className="mt-4 w-full rounded-lg btn-primary disabled:opacity-50 py-2 text-sm font-medium transition" className="mt-4"
> >
{T.watch} {T.watch}
</button> </Button>
</div> </div>
</div> </div>
)} )}
+7 -8
View File
@@ -8,6 +8,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { StatusPill, jobTitle } from "./downloadRow"; import { StatusPill, jobTitle } from "./downloadRow";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job // The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
@@ -110,19 +111,17 @@ function QuotaModal({
{t("downloads.admin.unlimited")} {t("downloads.admin.unlimited")}
</label> </label>
<div className="flex justify-between pt-1"> <div className="flex justify-between pt-1">
<button <Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
onClick={() => reset.mutate()}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.admin.reset")} {t("downloads.admin.reset")}
</button> </Button>
<button <Button
variant="primary"
size="sm"
onClick={() => save.mutate()} onClick={() => save.mutate()}
disabled={save.isPending} disabled={save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
> >
{t("downloads.admin.save")} {t("downloads.admin.save")}
</button> </Button>
</div> </div>
</div> </div>
</Modal> </Modal>
+11 -11
View File
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
import Modal from "./Modal"; import Modal from "./Modal";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconBtn } from "./downloadRow"; import { IconBtn } from "./downloadRow";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata // Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
@@ -101,13 +102,14 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
</div> </div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p> <p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end"> <div className="flex justify-end">
<button <Button
variant="primary"
size="sm"
onClick={() => save.mutate()} onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending} disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
> >
{t("downloads.edit.save")} {t("downloads.edit.save")}
</button> </Button>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -158,22 +160,20 @@ export function ErrorModal({
)} )}
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-end gap-2 pt-1">
{retryable && ( {retryable && (
<button <Button
variant="primary"
size="sm"
onClick={() => { onClick={() => {
onRetry(); onRetry();
onClose(); onClose();
}} }}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
> >
{t("downloads.actions.retry")} {t("downloads.actions.retry")}
</button> </Button>
)} )}
<button <Button variant="ghost" size="sm" onClick={onClose}>
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("common.close")} {t("common.close")}
</button> </Button>
</div> </div>
</div> </div>
</Modal> </Modal>
+12 -8
View File
@@ -1,5 +1,6 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Check, RotateCcw, Save } from "lucide-react"; import { Check, RotateCcw, Save } from "lucide-react";
import { Button } from "./Button";
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs, // The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
// admin Configuration). Two visual variants share one state machine + markup: // admin Configuration). Two visual variants share one state machine + markup:
@@ -58,24 +59,27 @@ export function DraftSaveBar({
)} )}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <Button
data-testid="draft-discard" data-testid="draft-discard"
variant="glass"
size="sm"
icon={<RotateCcw className="w-4 h-4" />}
onClick={onDiscard} onClick={onDiscard}
disabled={saving || !dirty} disabled={saving || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
> >
<RotateCcw className="w-4 h-4" />
{labels.discard} {labels.discard}
</button> </Button>
<button <Button
data-testid="draft-save" data-testid="draft-save"
variant="primary"
size="sm"
icon={<Save className="w-4 h-4" />}
onClick={onSave} onClick={onSave}
disabled={saving || !dirty} disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition" className="shadow-md shadow-accent/20"
> >
<Save className="w-4 h-4" />
{saving ? labels.saving : labels.save} {saving ? labels.saving : labels.save}
</button> </Button>
</div> </div>
</div> </div>
); );