mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
7f6eae8425
commit
a959db8679
7 files changed
+168
-157
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user