mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
refactor(ui): unify page headers and surface read failures
Add a PageHeader primitive (one H1 vocabulary, optional description, back-link, and action slots) and route the hand-rolled headers through it, so every module reads with the same title/subtitle rhythm and no icon beside the page title. Mental Wellbeing gains a visible header. Add a QueryErrorCard and wire the list surfaces so a failed read shows a retry card instead of falling through to the empty state, and swap their full-page spinners for skeletons that hold the layout.
This commit is contained in:
1 parent
336ce5aef3
commit
887c79ec86
16 files changed
+413
-378
No files matched your search
@@ -6,6 +6,7 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -125,19 +126,10 @@ export function CycleView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
data-tour-id="cycle-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("cycle.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("cycle.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PageHeader
|
||||
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
|
||||
description={t("cycle.subtitle")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -145,8 +137,8 @@ export function CycleView() {
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
|
||||
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { getHourForTimeZone } from "@/components/dashboard/range-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
|
||||
export function DashboardHeader({
|
||||
@@ -68,106 +68,102 @@ export function DashboardHeader({
|
||||
: t("dashboard.welcomeBack", { greeting: timeGreeting });
|
||||
|
||||
return (
|
||||
/* With the greeting the title block is two lines on narrow
|
||||
viewports: centre the buttons against it on mobile, top-align on
|
||||
sm+ (the pre-hero posture). Without the greeting the single-line
|
||||
centre alignment stands. */
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4",
|
||||
showGreeting && "sm:items-start",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
{showGreeting ? (
|
||||
<PageHeader
|
||||
title={t("dashboard.title")}
|
||||
description={
|
||||
showGreeting ? (
|
||||
/* `min-h-5` reserves the greeting's line box from the SSR
|
||||
pass on: the text personalises (name appended) on the first
|
||||
client re-render after hydration, and the reserved line
|
||||
keeps the header from collapsing/growing around that swap. */
|
||||
<p
|
||||
<span
|
||||
data-slot="dashboard-header-greeting"
|
||||
className="text-muted-foreground mt-1 min-h-5 truncate text-sm"
|
||||
className="block min-h-5 truncate"
|
||||
>
|
||||
{welcomeText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
(the tile/layout editor at /settings/dashboard). Sits to
|
||||
the left of the add button as a monochrome ghost icon, with
|
||||
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
|
||||
touch-target contract the add button also honours, shrinking
|
||||
back to the 40 px icon footprint on sm+. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
(the tile/layout editor at /settings/dashboard). Sits to
|
||||
the left of the add button as a monochrome ghost icon, with
|
||||
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
|
||||
touch-target contract the add button also honours, shrinking
|
||||
back to the 40 px icon footprint on sm+. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
across viewports. The v1.4.27 fix pinned a `size="sm"
|
||||
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
|
||||
contract on mobile, but `size="sm"` is h-8 (32 px) and
|
||||
the `min-h-11` override stretched the cap vertically
|
||||
while keeping the small horizontal padding — the
|
||||
button read as klobig on Pixel 5. Switch to
|
||||
`size="default"` (h-10 = 40 px) on mobile with a
|
||||
responsive `min-h-11 sm:min-h-9` so the button is
|
||||
44 px tall under finger pressure and shrinks back to
|
||||
the desktop-friendly 36 px on `sm:` upwards. The icon
|
||||
+ label keep the same visual contract. */}
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
<Link
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-w-[calc(100vw-2rem)]">
|
||||
{/* Menu items must each carry a self-contained verb-phrase
|
||||
("Log measurement", "Log mood") — the trigger above already
|
||||
says "Add", and the icon is `aria-hidden`, so the visible
|
||||
text is the only thing distinguishing the rows. v1.4.15
|
||||
phase-A3 fix #1 hardened this with a unit guard at
|
||||
`src/app/__tests__/quick-add-labels.test.ts` — both labels
|
||||
must differ from each other AND from `common.add`. */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.37 W7b — third quick-add row: medication intake.
|
||||
Same Sheet-on-mobile / Dialog-on-desktop primitive as
|
||||
the other two; the menu label is a self-contained
|
||||
verb-phrase so it doesn't collide with the trigger or
|
||||
the sibling rows (cf. quick-add-labels.test.ts). */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("medicationIntake")}>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
across viewports. The v1.4.27 fix pinned a `size="sm"
|
||||
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
|
||||
contract on mobile, but `size="sm"` is h-8 (32 px) and
|
||||
the `min-h-11` override stretched the cap vertically
|
||||
while keeping the small horizontal padding — the
|
||||
button read as klobig on Pixel 5. Switch to
|
||||
`size="default"` (h-10 = 40 px) on mobile with a
|
||||
responsive `min-h-11 sm:min-h-9` so the button is
|
||||
44 px tall under finger pressure and shrinks back to
|
||||
the desktop-friendly 36 px on `sm:` upwards. The icon
|
||||
+ label keep the same visual contract. */}
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
{/* Menu items must each carry a self-contained verb-phrase
|
||||
("Log measurement", "Log mood") — the trigger above already
|
||||
says "Add", and the icon is `aria-hidden`, so the visible
|
||||
text is the only thing distinguishing the rows. v1.4.15
|
||||
phase-A3 fix #1 hardened this with a unit guard at
|
||||
`src/app/__tests__/quick-add-labels.test.ts` — both labels
|
||||
must differ from each other AND from `common.add`. */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.37 W7b — third quick-add row: medication intake.
|
||||
Same Sheet-on-mobile / Dialog-on-desktop primitive as
|
||||
the other two; the menu label is a self-contained
|
||||
verb-phrase so it doesn't collide with the trigger or
|
||||
the sibling rows (cf. quick-add-labels.test.ts). */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,7 @@
|
||||
*/
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Stethoscope,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -35,6 +29,8 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -379,46 +375,39 @@ export function IllnessView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
data-tour-id="illness-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("illness.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("illness.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
<PageHeader
|
||||
title={<span data-tour-id="illness-hero">{t("illness.title")}</span>}
|
||||
description={t("illness.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/settings/layout/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
|
||||
the one-time acknowledgment now lives at onboarding and the legal
|
||||
@@ -430,23 +419,12 @@ export function IllnessView() {
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="illness-list-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("illness.listLoadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refetch()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
// A read failure is NOT an empty journal — surface the error + Retry so
|
||||
// an outage never reads as "no episodes yet".
|
||||
<QueryErrorCard
|
||||
title={t("illness.listLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : hasEpisodes ? (
|
||||
<>
|
||||
{/* Retrospective summary sits above the list so a long history
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MedicationCardHeader } from "@/components/medications/medication-card-h
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -93,7 +94,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: () =>
|
||||
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
|
||||
@@ -149,10 +150,13 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// A read failure is NOT an empty list — surface the error + Retry so an
|
||||
// outage never reads as "you have no lab results".
|
||||
return (
|
||||
<p role="alert" className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
<QueryErrorCard
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import {
|
||||
FilterBar,
|
||||
@@ -461,7 +463,7 @@ export function MeasurementList({
|
||||
? "groupBy=day"
|
||||
: "raw";
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.measurementsList({
|
||||
type: typeFilter === "ALL" ? undefined : typeFilter,
|
||||
sourceEq,
|
||||
@@ -825,9 +827,15 @@ export function MeasurementList({
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<div className="space-y-2" data-slot="measurement-list-loading">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
// A read failure is NOT an empty list — surface the error + Retry so
|
||||
// an outage never reads as "you have no measurements".
|
||||
<QueryErrorCard onRetry={() => void refetch()} />
|
||||
) : !data?.measurements?.length ? (
|
||||
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
|
||||
// Filter-aware copy distinguishes brand-new accounts ("no
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
@@ -107,14 +108,12 @@ export function MentalWellbeing() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{phase === "choose" && (
|
||||
<>
|
||||
<header className="flex flex-col gap-2">
|
||||
{/* Title kept for screen readers / document outline only — the
|
||||
visible heading + its tooltip were removed (§ Marc v1.25.5). */}
|
||||
<h1 className="sr-only">{t("mentalHealth.pageTitle")}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
</header>
|
||||
{/* v1.26 — visible page header, consistent with every other module
|
||||
(the title was previously sr-only; the description stays). */}
|
||||
<PageHeader
|
||||
title={t("mentalHealth.pageTitle")}
|
||||
description={t("mentalHealth.pageDescription")}
|
||||
/>
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
FilterBar,
|
||||
FilterBarDateRange,
|
||||
@@ -43,7 +45,6 @@ import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Smile,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
@@ -500,27 +501,18 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</FilterBar>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<div className="space-y-2" data-slot="mood-list-loading">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="mood-list-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("mood.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refetch()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
// A read failure is NOT an empty list — surface the error + Retry so
|
||||
// an outage never reads as "you have no mood entries".
|
||||
<QueryErrorCard
|
||||
title={t("mood.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : !data?.entries?.length ? (
|
||||
// v1.4.15 phase-C5: replace bare-text empty rectangle with
|
||||
// EmptyState. Filter-aware copy splits "no mood entries yet"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The one canonical page header. Every module surface renders its title
|
||||
* through this so the header vocabulary reads identically app-wide: the H1
|
||||
* is always `text-2xl font-bold tracking-tight`, the description is always
|
||||
* `text-muted-foreground text-sm`, and no icon sits beside the H1. An
|
||||
* optional back-link rides above the title, and an `actions` slot holds the
|
||||
* page's primary buttons to the right of the title block.
|
||||
*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
backLink,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
backLink?: { href: string; label: string; dataSlot?: string };
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{backLink ? <BackLink {...backLink} /> : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Read-failure card for list / data surfaces.
|
||||
*
|
||||
* Mirrors `<ChartErrorState>`'s shape + tone (AlertTriangle, a title line, a
|
||||
* muted description, an optional Retry button) but renders inside a `Card` so
|
||||
* a list page can tell "load failed" apart from an honest empty state — a
|
||||
* failed query must never fall through to the "no data yet" copy. Defaults are
|
||||
* the generic `common.*` strings so a caller can drop it in with just
|
||||
* `onRetry`; a surface with its own "couldn't load X" string passes it through
|
||||
* `title` / `description`.
|
||||
*/
|
||||
export function QueryErrorCard({
|
||||
title,
|
||||
description,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Card data-slot="query-error-card" className={cn(className)}>
|
||||
<CardContent
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{retryLabel ?? t("common.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user