From a959db8679118b05eed7cbfe02eb624c158d7459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 11:27:18 +0200 Subject: [PATCH] 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. --- src/app/notifications/page.tsx | 20 +-- .../dashboard/recent-workouts-tile.tsx | 169 +++++++++--------- .../settings/account-section/index.tsx | 14 +- .../settings/ai/runtime-actions-row.tsx | 96 +++++----- src/components/settings/api-section.tsx | 4 +- src/components/settings/mcp-section.tsx | 17 +- src/components/settings/sharing-section.tsx | 5 +- 7 files changed, 168 insertions(+), 157 deletions(-) diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index db199f853..ba17b08c5 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -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} /> -
-
- -

- {t("notifications.noChannels")} -

- {/* Channel setup lives in Settings → Integrations since v1.25.7; - point the CTA at that anchor, not the reminder-types screen. */} + } + 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={ -
-
+ } + /> ); } diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index cd4eab1ab..cf77c7eeb 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -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 ( -
- {/* 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. */} - - {t("dashboard.recentWorkouts.viewAll")} - - } - /> - - {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. -
- {[0, 1, 2].map((i) => ( -
- - - - -
- ))} -
- ) : isError ? ( - refetch()} - className="border-0 bg-transparent shadow-none" + + + {/* 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. */} + + {t("dashboard.recentWorkouts.viewAll")} + + } /> - ) : workouts.length === 0 ? ( -
-

{t("dashboard.recentWorkouts.empty.title")}

-

- {t("dashboard.recentWorkouts.empty.cta")} -

-
- ) : ( -
    - {workouts.map((workout) => { - const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; - const sportLabel = t(sportLabelKey); - const sportName = - sportLabel === sportLabelKey ? workout.sportType : sportLabel; - return ( -
  • - - {renderRow(workout, sportName)} - - {formatDayLabel(workout.startedAt, locale)} - - - {formatDuration(workout.durationSec)} - - -
  • - ); - })} -
- )} -
+ + + {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. +
+ {[0, 1, 2].map((i) => ( +
+ + + + +
+ ))} +
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> + ) : workouts.length === 0 ? ( +
+

+ {t("dashboard.recentWorkouts.empty.title")} +

+

+ {t("dashboard.recentWorkouts.empty.cta")} +

+
+ ) : ( +
    + {workouts.map((workout) => { + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + const sportName = + sportLabel === sportLabelKey ? workout.sportType : sportLabel; + return ( +
  • + + {renderRow(workout, sportName)} + + {formatDayLabel(workout.startedAt, locale)} + + + {formatDuration(workout.durationSec)} + + +
  • + ); + })} +
+ )} +
+ ); } diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 5d223b5fd..795852dd0 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -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 ( -
- -
+ + +
+ + + {t("common.loading")} + +
+
); } diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index 4bdc3e181..ddc043a4f 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -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.
-
+ {canRegenerate && ( +
+
+
+

{t("settings.rawData")}

+

+ {privacyMode === "raw" + ? t("settings.rawDataOnDescription") + : t("settings.rawDataOffDescription")} +

+
+ +
+ {privacyMode === "raw" && ( +
+ {t("settings.rawDataWarning")} +
+ )} +
+ )} + + {lastInsightLine && ( +

{lastInsightLine}

+ )} + {testMsg && ( +

+ {testMsg} +

+ )} + {regenMsg && ( +

+ {regenMsg} +

+ )} + +
- - {canRegenerate && ( -
-
-
-

{t("settings.rawData")}

-

- {privacyMode === "raw" - ? t("settings.rawDataOnDescription") - : t("settings.rawDataOffDescription")} -

-
- -
- {privacyMode === "raw" && ( -
- {t("settings.rawDataWarning")} -
- )} -
- )} - - {testMsg && ( -

- {testMsg} -

- )} - {regenMsg && ( -

- {regenMsg} -

- )} +
); } diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index af2565527..542d25fbd 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -406,9 +406,9 @@ function ApiTokensCard() {