feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
This commit is contained in:
@@ -29,6 +29,7 @@ import { formatDateTime, relativeTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
@@ -42,7 +43,7 @@ import { useAccountPersistedState } from "../lib/storage";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -230,17 +231,17 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
className="selectable cursor-pointer min-w-0 flex-1 rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{displayName(u)}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
||||
@@ -283,36 +284,36 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.suspend.action")
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={
|
||||
<IconButton
|
||||
noTitle
|
||||
label={
|
||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||
}
|
||||
icon={
|
||||
u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
size="sm"
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
>
|
||||
{u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||
>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("users.delete.action")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onDelete(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={t("users.delete.action")}
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
|
||||
the role button before it vary in width row to row, so only the row's own right
|
||||
@@ -459,15 +460,16 @@ function PendingAccessRowView({
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("users.pendingAccess.resend")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
@@ -729,18 +731,15 @@ function AdminInvites() {
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
noTitle
|
||||
onClick={() => onInvite(i)}
|
||||
disabled={invite.isPending}
|
||||
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
||||
i.invited_at
|
||||
? "text-muted/50 hover:text-fg"
|
||||
: "text-muted hover:text-accent"
|
||||
}`}
|
||||
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
>
|
||||
<Mails className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
{/* Already invited: the glyph fades, the action stays (a resend). */}
|
||||
<Mails className={i.invited_at ? "opacity-50" : ""} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
@@ -748,26 +747,24 @@ function AdminInvites() {
|
||||
{i.has_account ? (
|
||||
// `disabled` would drop it from the tab order AND suppress the title, so the
|
||||
// reason reached nobody — same call as the Playlists drag handle.
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
|
||||
label={t("settings.invites.deleteHasAccount")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
noTitle
|
||||
variant="danger"
|
||||
onClick={() => onDelete(i)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||
label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -841,15 +838,16 @@ function AdminDemo() {
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.demo.remove")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(r)}
|
||||
disabled={remove.isPending}
|
||||
aria-label={t("settings.demo.remove")}
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -877,7 +875,11 @@ function AdminDemo() {
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<Tooltip hint={t("settings.demo.resetHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<RotateCcw />}
|
||||
loading={reset.isPending}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
@@ -889,12 +891,9 @@ function AdminDemo() {
|
||||
)
|
||||
reset.mutate();
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.demo.reset")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -928,26 +927,28 @@ function InviteRow({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.invites.approve")}
|
||||
size="sm"
|
||||
variant="outline-accent"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onApprove}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.invites.approve")}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.invites.deny")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDeny}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.invites.deny")}
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</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
|
||||
@@ -955,27 +956,26 @@ function InviteRow({
|
||||
silently missing a button their other rows have. */}
|
||||
{inv.has_account ? (
|
||||
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="outline"
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
|
||||
label={t("settings.invites.deleteHasAccount")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user