refactor(ui): route settings shells, actions, and empty states through primitives

Bring the remaining settings and dashboard surfaces onto the shared
anatomy: the API and MCP revoke buttons become the destructive variant
instead of an outline hand-tinted red; the runtime-actions row places
its raw-data setting and status lines above the actions, not under them;
the account section paints its card and header while loading instead of a
lone spinner; the share revoke becomes a right-aligned row action rather
than a full-width alarm strip per link; the recent-workouts tile and the
MCP and notifications empty states compose Card and EmptyState instead of
hand-rolled bg-card shells.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 11:27:18 +02:00
1 parent 7f6eae8425
commit a959db8679
7 files changed
+168 -157

No files matched your search

+10 -10
View File
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
import { Bell, Settings, AlertCircle } from "lucide-react";
@@ -201,22 +202,21 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
actions={headerActions}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
<Bell className="text-muted-foreground h-12 w-12" />
<p className="text-muted-foreground text-sm">
{t("notifications.noChannels")}
</p>
{/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */}
<EmptyState
className="max-w-2xl"
icon={<Bell className="size-6" />}
title={t("notifications.noChannels")}
/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */
action={
<Button asChild variant="outline">
<Link href="/settings/integrations#channels">
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
</Button>
</div>
</div>
}
/>
</div>
);
}
@@ -6,6 +6,7 @@ import { Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { Skeleton } from "@/components/ui/skeleton";
@@ -78,90 +79,90 @@ export function RecentWorkoutsTile() {
const workouts = data?.workouts ?? [];
return (
<div
data-slot="recent-workouts-tile"
className={cn(
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
)}
>
{/* Canonical tile header (foreground icon + title) — the same
`TileHeader` contract the Insights tiles use, so the dashboard
stops speaking a separate header language. `size="sm"` matches the
compact dashboard tile. */}
<TileHeader
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
titleAs="h2"
right={
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
}
/>
{showLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
// (3 workouts × ~3 rem rows + spacing) so the surrounding
// dashboard layout doesn't reflow once the data lands.
// v1.29.x — row-shaped skeletons instead of a "loading…" text
// line, matching the sport-icon + label + day + duration shape
// the loaded rows render.
<div data-slot="recent-workouts-loading" className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 px-1 py-1">
<Skeleton className="size-7 shrink-0 rounded-full" />
<Skeleton className="h-4 min-w-0 flex-1" />
<Skeleton className="h-4 w-12 shrink-0" />
<Skeleton className="h-4 w-8 shrink-0" />
</div>
))}
</div>
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
<Card data-slot="recent-workouts-tile" className="h-full">
<CardHeader>
{/* Canonical tile header (foreground icon + title) — the same
`TileHeader` contract the Insights tiles use, so the dashboard
stops speaking a separate header language. `size="sm"` matches the
compact dashboard tile. */}
<TileHeader
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
titleAs="h2"
right={
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
}
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">{t("dashboard.recentWorkouts.empty.title")}</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
</div>
) : (
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="recent-workouts-link"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</CardHeader>
<CardContent>
{showLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
// (3 workouts × ~3 rem rows + spacing) so the surrounding
// dashboard layout doesn't reflow once the data lands.
// v1.29.x — row-shaped skeletons instead of a "loading…" text
// line, matching the sport-icon + label + day + duration shape
// the loaded rows render.
<div data-slot="recent-workouts-loading" className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 px-1 py-1">
<Skeleton className="size-7 shrink-0 rounded-full" />
<Skeleton className="h-4 min-w-0 flex-1" />
<Skeleton className="h-4 w-12 shrink-0" />
<Skeleton className="h-4 w-8 shrink-0" />
</div>
))}
</div>
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">
{t("dashboard.recentWorkouts.empty.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
</div>
) : (
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="recent-workouts-link"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -285,10 +285,18 @@ export function AccountSection() {
}
if (!mounted || isLoading) {
// §13 — a loading section paints its card + header, not a bare centered
// spinner (reference: admin/coach-feedback-section).
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
<SettingsCard>
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
</SettingsCard>
);
}
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -199,8 +200,54 @@ export function RuntimeActionsRow({
void userProvider;
return (
// §12 — the action row is the LAST thing in the block. The raw-data
// setting and every status line (last-generated, test / regenerate
// result) sit ABOVE it, never under it.
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{canRegenerate && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
<p className="text-muted-foreground text-xs">
{privacyMode === "raw"
? t("settings.rawDataOnDescription")
: t("settings.rawDataOffDescription")}
</p>
</div>
<Switch
aria-label={t("settings.rawData")}
checked={privacyMode === "raw"}
onCheckedChange={togglePrivacy}
/>
</div>
{privacyMode === "raw" && (
<div className="bg-warning/15 text-warning mt-2 rounded-lg p-2 text-xs">
{t("settings.rawDataWarning")}
</div>
)}
</div>
)}
{lastInsightLine && (
<p className="text-muted-foreground text-xs">{lastInsightLine}</p>
)}
{testMsg && (
<p
className={`text-xs ${testOk ? "text-success" : "text-destructive"}`}
>
{testMsg}
</p>
)}
{regenMsg && (
<p
className={`text-xs ${regenOk ? "text-success" : "text-destructive"}`}
>
{regenMsg}
</p>
)}
<SettingsCardActions align="start">
<Button
type="button"
size="sm"
@@ -233,52 +280,7 @@ export function RuntimeActionsRow({
{t("settings.regenerateInsights")}
</Button>
)}
{lastInsightLine && (
<span className="text-muted-foreground text-xs">
{lastInsightLine}
</span>
)}
</div>
{canRegenerate && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
<p className="text-muted-foreground text-xs">
{privacyMode === "raw"
? t("settings.rawDataOnDescription")
: t("settings.rawDataOffDescription")}
</p>
</div>
<Switch
aria-label={t("settings.rawData")}
checked={privacyMode === "raw"}
onCheckedChange={togglePrivacy}
/>
</div>
{privacyMode === "raw" && (
<div className="bg-warning/15 text-warning mt-2 rounded-lg p-2 text-xs">
{t("settings.rawDataWarning")}
</div>
)}
</div>
)}
{testMsg && (
<p
className={`text-xs ${testOk ? "text-success" : "text-destructive"}`}
>
{testMsg}
</p>
)}
{regenMsg && (
<p
className={`text-xs ${regenOk ? "text-success" : "text-destructive"}`}
>
{regenMsg}
</p>
)}
</SettingsCardActions>
</div>
);
}
+2 -2
View File
@@ -406,9 +406,9 @@ function ApiTokensCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
+7 -10
View File
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -122,9 +123,7 @@ function McpConnectionsCard() {
) : isLoading ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : list.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.mcp.noConnections")}
</p>
<EmptyState size="compact" title={t("settings.mcp.noConnections")} />
) : (
<ul className="space-y-2">
{list.map((conn) => (
@@ -157,9 +156,9 @@ function McpConnectionsCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.mcp.connectionRevoke")}
@@ -440,9 +439,7 @@ function McpTokensCard() {
) : isLoading ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : activeTokens.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.noActiveTokens")}
</p>
<EmptyState size="compact" title={t("settings.noActiveTokens")} />
) : (
<ul className="space-y-2">
{activeTokens.map((tok) => {
@@ -490,9 +487,9 @@ function McpTokensCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
+4 -1
View File
@@ -236,12 +236,15 @@ function ShareLinksCard() {
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11 w-full sm:min-h-9"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}