- Button: `loading` always disables (|| not ??) — stops a double-submit when a call-site passes an explicit boolean `disabled` (VideoEditor). - playlists: track un-ingestable-but-live YouTube ids in the sync baseline so a linked playlist containing a private/deleted video converges instead of re-classifying it as a pending pull forever; re-attempt ingestion each sync so a restored (private->public) video heals, and prune ids that leave YouTube. - SettingsPanel: download-auth section renders an honest error/loading state (StateMessage + retry) instead of a bare empty section on a failed credentials fetch; HU+EN loadError string. - tests: reorder-order, partial-failure baseline hold, un-ingestable converge/heal/prune, Button loading-disables, download-auth error/retry.
104 lines
4.1 KiB
TypeScript
104 lines
4.1 KiB
TypeScript
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 <button>s styling themselves with
|
|
// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
|
|
// constant. This centralises the geometry+paint so an "action button" (submit/cancel/subscribe/delete…)
|
|
// is one component with named variants. It deliberately does NOT cover the app's OTHER button families,
|
|
// which keep their own primitives: aria-pressed segmented pills (SegmentedToggle/SortControl), the
|
|
// Switch toggle, .chip removers, dropdown/popover trigger pills, and slider/overlay/card-click buttons.
|
|
//
|
|
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
|
// users. Adding one would double the ring.
|
|
|
|
type ButtonVariant =
|
|
| "primary" // solid accent — the main affirmative action
|
|
| "danger" // solid danger — destructive confirm
|
|
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
|
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
|
|
| "glass" // glass card — the Plex/overlay action buttons
|
|
| "danger-outline"; // bordered danger — a destructive action that shouldn't shout
|
|
|
|
type ButtonSize =
|
|
| "xs" // dense inline action
|
|
| "sm" // the btnCls geometry (px-3 py-1.5)
|
|
| "md" // default action button (px-3 py-2)
|
|
| "lg" // prominent CTA
|
|
| "icon"; // square icon-only (w-9 h-9)
|
|
|
|
const VARIANTS: Record<ButtonVariant, string> = {
|
|
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
|
|
primary: "btn-primary",
|
|
danger: "btn-danger",
|
|
outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
|
|
ghost: "text-muted hover:text-fg hover:bg-surface",
|
|
glass: "glass-card glass-hover hover:text-accent",
|
|
"danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
|
};
|
|
|
|
const SIZES: Record<ButtonSize, string> = {
|
|
xs: "px-2 py-1 text-xs rounded-md gap-1",
|
|
sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
|
|
md: "px-3 py-2 text-sm rounded-lg gap-1.5",
|
|
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
|
|
icon: "w-9 h-9 rounded-lg justify-center",
|
|
};
|
|
|
|
const BASE =
|
|
"inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed";
|
|
|
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant;
|
|
size?: ButtonSize;
|
|
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
|
|
icon?: ReactNode;
|
|
/** Render only the icon: children are dropped and `label` supplies the accessible name. */
|
|
iconOnly?: boolean;
|
|
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
|
|
label?: string;
|
|
/** Disable + swap the icon for a spinner + mark aria-busy. */
|
|
loading?: boolean;
|
|
fullWidth?: boolean;
|
|
}
|
|
|
|
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
{
|
|
variant = "primary",
|
|
size = "md",
|
|
icon,
|
|
iconOnly = false,
|
|
label,
|
|
loading = false,
|
|
fullWidth = false,
|
|
className,
|
|
children,
|
|
disabled,
|
|
type,
|
|
...rest
|
|
},
|
|
ref,
|
|
) {
|
|
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type={type ?? "button"}
|
|
// `loading` must always disable (see the prop's contract above), even when a call-site also
|
|
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
|
|
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
|
|
disabled={disabled || loading}
|
|
aria-busy={loading || undefined}
|
|
aria-label={label}
|
|
title={label}
|
|
className={clsx(BASE, VARIANTS[variant], SIZES[size], fullWidth && "w-full", className)}
|
|
{...rest}
|
|
>
|
|
{leading}
|
|
{!iconOnly && children}
|
|
</button>
|
|
);
|
|
});
|