feat(e10): migrate the notification, message, auth and Plex action buttons
Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical accent text links, three ghost icon buttons, two ghost text actions), Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread, ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd, PlexCollectionEditor and plexViews. Moves the font-weight out of Button's BASE and into each variant, so the `link` variant can be semibold (which is what every text link in this app looks like) while the action variants stay medium, and no element can end up with two font-* utilities. Welcome is the one signed-out surface this session cannot see, so only its byte-identical link swaps were taken; its primary submit and the bg-card button family are left for a pass that can UAT the screen. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications, Messages and the nav rail, clean console in a fresh tab.
This commit is contained in:
@@ -416,14 +416,14 @@ export default function ChannelPage({
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
{ch?.subscribed && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Send } from "lucide-react";
|
||||
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -213,15 +214,16 @@ export default function ChatThread({
|
||||
placeholder={t("messages.writePlaceholder")}
|
||||
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
|
||||
aria-label={t("messages.send")}
|
||||
title={t("messages.send")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
@@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelReset}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={doReset}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t("messages.resetButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
@@ -123,13 +124,10 @@ function ConversationList({
|
||||
{t("messages.conversations")}
|
||||
</div>
|
||||
{ready && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="xs" onClick={onNew}>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||
@@ -213,12 +211,12 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -280,12 +278,12 @@ function PageThread({
|
||||
return (
|
||||
<div className="flex flex-col h-[65vh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<Avatar
|
||||
src={seedAvatar}
|
||||
fallback={name}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Page } from "../lib/urlState";
|
||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type LangCode } from "../i18n";
|
||||
import { Button } from "./ui/Button";
|
||||
import AvatarImg from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
@@ -221,22 +222,22 @@ export default function NavSidebar({
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
||||
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenAbout}
|
||||
title={t("header.account.about")}
|
||||
aria-label={t("header.account.about")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Info className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
icon={<Info className="w-5 h-5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
title={t("header.account.signOut")}
|
||||
aria-label={t("header.account.signOut")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
|
||||
<button
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LEVEL_STYLE } from "./ui/Toaster";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
@@ -132,22 +133,24 @@ export default function NotificationsPanel() {
|
||||
</div>
|
||||
{hasAny && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={markAll}
|
||||
disabled={!hasUnread || readAllMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<CheckCheck className="w-4 h-4" />}
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
{t("inbox.markAllRead")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={clearEverything}
|
||||
disabled={clearMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("inbox.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,43 +308,45 @@ function NotificationRow({
|
||||
</ul>
|
||||
)}
|
||||
{isScheduler && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenScheduler}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Activity className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
{t("inbox.openScheduler")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{isDownloadDone && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenDownloads}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("inbox.openDownloads")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRead}
|
||||
title={t("inbox.markRead")}
|
||||
aria-label={t("inbox.markRead")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -396,47 +401,52 @@ function ClientActivityRow({
|
||||
</div>
|
||||
{hidden ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Eye className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{t("notifications.unhide")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : watched ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("notifications.unwatch")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : subscribed ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFocusChannel(subscribed.channelName)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Tv className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Tv className="w-3.5 h-3.5" />
|
||||
{t("notifications.openInManager")}
|
||||
</button>
|
||||
</Button>
|
||||
<a
|
||||
href={channelYouTubeUrl(subscribed.channelId)}
|
||||
target="_blank"
|
||||
@@ -448,36 +458,39 @@ function ClientActivityRow({
|
||||
</a>
|
||||
</div>
|
||||
) : access ? (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1"
|
||||
onClick={() => onReviewAccess()}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
|
||||
icon={<UserPlus className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("common.accessRequestsReview")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
n.action &&
|
||||
!n.dismissed && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onRemove}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
@@ -138,14 +139,16 @@ export default function PlexCollectionEditor({
|
||||
placeholder={t("plex.collEditor.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || !!busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.collEditor.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { api, type PlexPlaylist } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
|
||||
@@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({
|
||||
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.playlistAdd.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -35,6 +35,7 @@ import { api, type PlexCard } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
|
||||
@@ -514,13 +515,10 @@ export default function PlexPlaylistView({
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -138,13 +138,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
value={fullUrl}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={copy}
|
||||
title={t("downloads.share.copy")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||
>
|
||||
<Button variant="ghost" size="icon-sm" onClick={copy} title={t("downloads.share.copy")}>
|
||||
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewPass("");
|
||||
|
||||
@@ -28,6 +28,7 @@ import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api"
|
||||
import { HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
@@ -550,9 +551,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
{verifyOutcome === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
<Button variant="link" onClick={goForgot}>
|
||||
{t("welcome.auth.verifyOkResetCta")}
|
||||
</button>
|
||||
</Button>
|
||||
</Banner>
|
||||
)}
|
||||
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
|
||||
@@ -564,9 +565,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
{verifyOutcome === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
<button onClick={goResend} className="font-semibold text-accent hover:underline">
|
||||
<Button variant="link" onClick={goResend}>
|
||||
{t("welcome.auth.resendCta")}
|
||||
</button>
|
||||
</Button>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "requested" &&
|
||||
@@ -679,15 +680,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
<div className="flex items-center justify-between mt-3 text-xs">
|
||||
{mode === "signin" ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setMode("register");
|
||||
}}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("welcome.auth.createLink")}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
@@ -699,15 +700,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setMode("signin");
|
||||
}}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -820,9 +821,9 @@ function StatePanel({
|
||||
</p>
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={onBack} className="text-sm text-accent hover:underline">
|
||||
<Button variant="link" className="text-sm" onClick={onBack}>
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
</Button>
|
||||
{cta && (
|
||||
<button
|
||||
onClick={cta.onClick}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
WatchedToggle,
|
||||
} from "../lib/plexDetailUi";
|
||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
@@ -56,13 +57,10 @@ export function PlexInfoView({
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -166,13 +164,10 @@ export function PlexPersonView({
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !d ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
|
||||
@@ -36,26 +36,28 @@ type ButtonSize =
|
||||
|
||||
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",
|
||||
primary: "font-medium btn-primary",
|
||||
danger: "font-medium btn-danger",
|
||||
// E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the
|
||||
// un-migrated call sites showed the majority hovering to `accent` (outline) and filling with `card`
|
||||
// (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites
|
||||
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
|
||||
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
|
||||
outline: "border border-border text-muted hover:text-accent hover:border-accent",
|
||||
"outline-accent": "border border-accent/40 text-accent hover:bg-accent/10",
|
||||
outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent",
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent hover:bg-accent/10",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
// is whether the button should advertise the destruction before the pointer arrives.
|
||||
"outline-danger": "border border-border text-muted hover:text-danger hover:border-danger/40",
|
||||
"outline-warn": "border border-warn/40 text-warn hover:bg-warn/10",
|
||||
ghost: "text-muted hover:text-fg hover:bg-card",
|
||||
glass: "glass-card glass-hover hover:text-accent",
|
||||
"danger-soft": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
||||
"outline-danger":
|
||||
"font-medium border border-border text-muted hover:text-danger hover:border-danger/40",
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn hover:bg-warn/10",
|
||||
ghost: "font-medium text-muted hover:text-fg hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover hover:text-accent",
|
||||
"danger-soft":
|
||||
"font-medium border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
||||
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
|
||||
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
|
||||
// the winner is decided by the generated stylesheet order, not by the class list order).
|
||||
link: "text-accent hover:underline",
|
||||
link: "font-semibold text-accent hover:underline",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
@@ -68,8 +70,11 @@ const SIZES: Record<ButtonSize, string> = {
|
||||
none: "gap-1.5",
|
||||
};
|
||||
|
||||
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
|
||||
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
|
||||
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
|
||||
Reference in New Issue
Block a user