feat(e10): finish the mappable Button migration, including the ui/ layer itself
Third sweep, 20 more sites: the rest of AdminUsers (the access-request and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed, KeyGate, OnboardingWizard -- and, importantly, four platform components that were still hand-rolling their own buttons: Modal's close, Banner's dismiss, SidePanel's two toggles and QueryState's retry. A platform layer that bypasses its own primitive is the same defect one level up. Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites 37 -> 106. What is left is deliberate, and it is the list Button's own docstring excludes plus two families this pass refused to force: video overlay controls painted on white/N, drag handles, chip removers, accent-solid pills, the bg-card family on the signed-out screen, and sites whose paint is conditional (`suspended ? warn : neutral`) or otherwise has no variant yet. Forcing those through a className override would be unsafe without tailwind-merge, which is exactly the hazard this sprint documented. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the About dialog (its close button and full-width CTA are both migrated), clean console.
This commit is contained in:
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Info, Sparkles } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d?: string | null): string {
|
||||
@@ -70,12 +71,9 @@ export default function About({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
|
||||
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
|
||||
</button>
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -828,14 +828,15 @@ function AdminDemo() {
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(r)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.demo.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -856,12 +857,9 @@ function AdminDemo() {
|
||||
placeholder={t("settings.demo.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="md" className="shrink-0" type="submit">
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
@@ -917,24 +915,26 @@ function InviteRow({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onApprove}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.approve")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDeny}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deny")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
|
||||
exists for it the row is that account's approval gate, so it is managed from Users & roles
|
||||
@@ -954,14 +954,15 @@ function InviteRow({
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
@@ -530,20 +531,18 @@ export default function Feed({
|
||||
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||
</p>
|
||||
{isDemo ? (
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
{t("feed.browseLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
||||
{t("feed.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
|
||||
@@ -154,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !pass}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
{t("messages.forgotPass")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import { Button } from "./ui/Button";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
@@ -159,12 +160,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Button variant="ghost" size="md" onClick={close}>
|
||||
{t("onboarding.read.skip")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
||||
</button>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("onboarding.write.keepReadOnly")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { relativeTime } from "../lib/format";
|
||||
@@ -283,13 +284,9 @@ function MaintenanceSettings({
|
||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-1.5">
|
||||
{t("scheduler.maintenance.batchNote", {
|
||||
|
||||
@@ -495,13 +495,15 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
{t("settings.account.sessions.hint")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={logoutOthers}
|
||||
disabled={sessBusy}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
|
||||
>
|
||||
{t("settings.account.sessions.action")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -830,13 +832,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||
{t("settings.account.deleteHint")}
|
||||
</p>
|
||||
<button
|
||||
onClick={onDeleteAccount}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-danger/40 text-danger hover:bg-danger/10 transition"
|
||||
>
|
||||
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("settings.account.deleteAccount")}
|
||||
</button>
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
|
||||
@@ -239,15 +240,17 @@ export default function SetupWizard() {
|
||||
placeholder={t("setup.smtp.testTo")}
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
type="button"
|
||||
onClick={sendTest}
|
||||
disabled={testState === "sending" || !testTo.trim()}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
|
||||
{t("setup.smtp.test")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{testState === "ok" && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
type BannerTone = "accent" | "warning";
|
||||
|
||||
@@ -56,13 +57,14 @@ export default function Banner({
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-auto"
|
||||
onClick={onDismiss}
|
||||
title={dismissTitle}
|
||||
className="ml-auto p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X } from "lucide-react";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||
@@ -141,14 +142,15 @@ export default function Modal({
|
||||
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
|
||||
// visual for a page/section's first load, so all of them read the same (and can be swapped for
|
||||
@@ -48,13 +49,9 @@ export function StateMessage({
|
||||
{children}
|
||||
</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" type="button" onClick={onRetry}>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
||||
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
||||
@@ -101,14 +102,16 @@ export default function SidePanel({
|
||||
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
|
||||
Module actions live on their own row below so the title always has room. */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 -ml-1"
|
||||
onClick={onToggleCollapse}
|
||||
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
</Button>
|
||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
@@ -133,13 +136,14 @@ export default function SidePanel({
|
||||
</span>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onReset}
|
||||
title={t("sidebar.resetDefaults")}
|
||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<RotateCcw className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
{onToggleEditing && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user