mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(settings): route every card header through SettingsCardHeader
Hand-rolled section headers had drifted into a dozen variants — some without an icon, some with a text-sm description instead of the shared text-xs, the data-reset title once even tinted red. Route the remaining ad-hoc headers through the SettingsCardHeader primitive so icon sizing, title weight and description size live in one place. Give the integrations connections group (the data sources) its own labelled header for parity with the delivery-channels group below it. A coverage test now globs every settings *-section.tsx / *-card.tsx and fails on any hand-rolled <h2 text-lg> outside the primitive, with a short allowlist for the collapsible-toggle security cards and the layout section pending its hub rebuild.
This commit is contained in:
1 parent
f207f1a7d9
commit
304f0c7057
16 files changed
+323
-228
No files matched your search
@@ -4039,6 +4039,8 @@
|
||||
"title": "Integrationen",
|
||||
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
|
||||
"subtitle": "Datenquellen und Geräte verbinden und verwalten.",
|
||||
"connectionsHeading": "Verbindungen",
|
||||
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
|
||||
"channelsHeading": "Zustellkanäle",
|
||||
"channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
|
||||
},
|
||||
|
||||
@@ -4039,6 +4039,8 @@
|
||||
"title": "Integrations",
|
||||
"description": "Connected services, delivery channels, and source priority in one place.",
|
||||
"subtitle": "Connect and manage your data sources and devices.",
|
||||
"connectionsHeading": "Connections",
|
||||
"connectionsDescription": "Connect and manage your data sources and devices.",
|
||||
"channelsHeading": "Delivery channels",
|
||||
"channelsDescription": "Where reminders and alerts are delivered."
|
||||
},
|
||||
|
||||
@@ -4039,6 +4039,8 @@
|
||||
"title": "Integraciones",
|
||||
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
|
||||
"subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.",
|
||||
"connectionsHeading": "Conexiones",
|
||||
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
|
||||
"channelsHeading": "Canales de entrega",
|
||||
"channelsDescription": "Dónde se entregan los recordatorios y avisos."
|
||||
},
|
||||
|
||||
@@ -4039,6 +4039,8 @@
|
||||
"title": "Intégrations",
|
||||
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
|
||||
"subtitle": "Connectez et gérez vos sources de données et appareils.",
|
||||
"connectionsHeading": "Connexions",
|
||||
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
|
||||
"channelsHeading": "Canaux de diffusion",
|
||||
"channelsDescription": "Où sont envoyés les rappels et alertes."
|
||||
},
|
||||
|
||||
@@ -4039,6 +4039,8 @@
|
||||
"title": "Integrazioni",
|
||||
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
|
||||
"subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.",
|
||||
"connectionsHeading": "Connessioni",
|
||||
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
|
||||
"channelsHeading": "Canali di recapito",
|
||||
"channelsDescription": "Dove vengono recapitati promemoria e avvisi."
|
||||
},
|
||||
|
||||
@@ -4039,6 +4039,8 @@
|
||||
"title": "Integracje",
|
||||
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
|
||||
"subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
|
||||
"connectionsHeading": "Połączenia",
|
||||
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
|
||||
"channelsHeading": "Kanały dostarczania",
|
||||
"channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
|
||||
},
|
||||
|
||||
@@ -53,14 +53,17 @@ describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
|
||||
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
|
||||
const html = render();
|
||||
// The reset card's title used to ship with `text-destructive`; the
|
||||
// v1.4.43 fix pins it on `text-foreground`. We don't care if other
|
||||
// strings in the section happen to use `text-destructive` (the CTA
|
||||
// button does), only that the specific card title slot does NOT.
|
||||
// v1.4.43 fix neutralised it. The header now routes through the
|
||||
// canonical `<SettingsCardHeader>`, whose `<h2>` carries no colour
|
||||
// class at all and so inherits the neutral foreground. We don't care
|
||||
// if other strings in the section happen to use `text-destructive`
|
||||
// (the CTA button does), only that the specific card title slot does
|
||||
// NOT paint red.
|
||||
const cardMatch = html.match(
|
||||
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
|
||||
);
|
||||
expect(cardMatch).not.toBeNull();
|
||||
expect(cardMatch![0]).toContain("text-foreground");
|
||||
expect(cardMatch![0]).toContain("text-lg font-semibold");
|
||||
expect(cardMatch![0]).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Settings card-header drift guard.
|
||||
*
|
||||
* Every Settings card paints the same header shape — a muted icon, an
|
||||
* `<h2 class="text-lg font-semibold">` title, and an optional muted
|
||||
* description. The canonical primitive `SettingsCardHeader`
|
||||
* (`_card-header.tsx`) owns that shape; routing every card header through
|
||||
* it keeps the typography, the icon sizing, and the description size in
|
||||
* one place instead of drifting across hand-rolled `flex … <h2 text-lg`
|
||||
* permutations (the audit found five distinct variants once).
|
||||
*
|
||||
* This test globs every `*-section.tsx` / `*-card.tsx` under
|
||||
* `src/components/settings/` and flags any file that still hand-rolls a
|
||||
* top-level card header — a `<h2>` tag whose className carries `text-lg`.
|
||||
* The primitive itself is excluded (it OWNS the `<h2 text-lg>`), and a
|
||||
* short, explicitly-documented allowlist covers the handful of headers
|
||||
* that legitimately cannot use the primitive yet.
|
||||
*
|
||||
* Mirrors the call-site coverage guards under `src/__tests__/`: walk the
|
||||
* tree, match a mechanical pattern, assert the offending set is empty.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../../..");
|
||||
const SETTINGS_DIR = join(ROOT, "src/components/settings");
|
||||
|
||||
// Files allowed to retain a hand-rolled `<h2 text-lg>` header.
|
||||
const ALLOWLIST = new Set<string>([
|
||||
// TODO #148: rebuilt into hub+subpages, will use SettingsCardHeader then
|
||||
"layout-section.tsx",
|
||||
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
|
||||
"security-activity-card.tsx",
|
||||
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
|
||||
"security-sessions-card.tsx",
|
||||
]);
|
||||
|
||||
// The primitive itself owns the canonical `<h2 text-lg>`.
|
||||
const PRIMITIVE = "_card-header.tsx";
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === "__tests__" || name === "node_modules") continue;
|
||||
const p = join(dir, name);
|
||||
const stat = statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
walk(p, out);
|
||||
} else if (name.endsWith("-section.tsx") || name.endsWith("-card.tsx")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A hand-rolled card header: a `<h2` tag whose className (within a small
|
||||
// window, so a multi-line opening tag still matches) carries `text-lg`.
|
||||
const AD_HOC_HEADER = /<h2\b[\s\S]{0,160}?text-lg/;
|
||||
|
||||
describe("Settings card headers route through SettingsCardHeader", () => {
|
||||
it("has no hand-rolled <h2 text-lg> outside the primitive + allowlist", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of walk(SETTINGS_DIR)) {
|
||||
const base = file.slice(file.lastIndexOf("/") + 1);
|
||||
if (base === PRIMITIVE) continue;
|
||||
if (ALLOWLIST.has(base)) continue;
|
||||
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (AD_HOC_HEADER.test(source)) {
|
||||
offenders.push(relative(ROOT, file));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`These Settings files still hand-roll a card header (\`<h2 ... text-lg>\`).\n` +
|
||||
`Route them through <SettingsCardHeader> (src/components/settings/_card-header.tsx),\n` +
|
||||
`or, if a header genuinely cannot use the primitive, add it to the ALLOWLIST\n` +
|
||||
`in this test with a one-line justification.\n\n` +
|
||||
offenders.map((f) => ` - ${f}`).join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -473,27 +473,21 @@ export function AccountSection() {
|
||||
card's "Restart onboarding tour" actually broke through the
|
||||
right border by ~48 px). */}
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.passwordReset")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.changePasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
status={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.25.7 — active-session management + the security-activity feed
|
||||
|
||||
@@ -258,72 +258,69 @@ function DataResetCard() {
|
||||
GitHub-style (red CTA only) rather than red-on-red-on-red.
|
||||
The protective gate (confirmation dialog) is unchanged; this
|
||||
is purely a visual-tone fix per the v1.4.43 audit. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.dangerZone")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dangerZoneDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the destructive mutation is in
|
||||
// flight so the in-dialog pending state stays visible and a
|
||||
// stray backdrop tap can't dismiss it mid-request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.dangerZoneConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.dangerZoneConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<SettingsCardHeader
|
||||
icon={Trash2}
|
||||
title={t("settings.dangerZone")}
|
||||
description={t("settings.dangerZoneDescription")}
|
||||
status={
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the destructive mutation is in
|
||||
// flight so the in-dialog pending state stays visible and a
|
||||
// stray backdrop tap can't dismiss it mid-request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAllData();
|
||||
}}
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-data-reset-confirm"
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.dangerZoneConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.dangerZoneConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAllData();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-data-reset-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
@@ -398,73 +395,70 @@ function AccountDeleteCard() {
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.deleteAccountCardTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.deleteAccountCardDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<SettingsCardHeader
|
||||
icon={Trash2}
|
||||
title={t("settings.deleteAccountCardTitle")}
|
||||
description={t("settings.deleteAccountCardDescription")}
|
||||
status={
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
|
||||
@@ -52,6 +52,7 @@ import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
|
||||
@@ -336,24 +337,22 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
right-align-on-desktop contract as Account → Password +
|
||||
Restart onboarding tour. Avoids the long German "Auf Standard
|
||||
zurücksetzen" copy clipping the card edge at narrow widths. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.customizeTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={LayoutDashboard}
|
||||
title={t("dashboard.customizeTitle")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` page
|
||||
already renders a `settings.sections.dashboard.description`
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
* `/api/share-links` routes, and the public `/c/[token]` view are unchanged.
|
||||
*/
|
||||
|
||||
import { Share2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
|
||||
@@ -39,14 +42,11 @@ export function GesundheitsakteSection() {
|
||||
<HealthRecordExportPanel />
|
||||
|
||||
<section id="sharing" className="scroll-mt-28 space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.sharing.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sharing.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Share2}
|
||||
title={t("settings.sections.sharing.title")}
|
||||
description={t("settings.sections.sharing.subtitle")}
|
||||
/>
|
||||
<SharingSection />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { LayoutGrid, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
@@ -50,17 +51,12 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
aria-labelledby="insights-overview-arrange-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="insights-overview-arrange-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("insights.settings.overviewTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.settings.overviewDescription")}
|
||||
</p>
|
||||
</header>
|
||||
<SettingsCardHeader
|
||||
icon={LayoutGrid}
|
||||
titleId="insights-overview-arrange-title"
|
||||
title={t("insights.settings.overviewTitle")}
|
||||
description={t("insights.settings.overviewDescription")}
|
||||
/>
|
||||
|
||||
{/* Gate the editor mount until the layout GET settles. The editor seeds
|
||||
its draft once from `layout` on mount, which is the canonical default
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react";
|
||||
import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
/**
|
||||
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
|
||||
@@ -210,28 +211,26 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
aria-labelledby="insights-pill-order-title"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 id="insights-pill-order-title" className="text-lg font-semibold">
|
||||
{t("insights.pillOrder.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.pillOrder.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draftTiles)}
|
||||
disabled={busy || !dirty}
|
||||
data-slot="insights-pill-order-save"
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("insights.pillOrder.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={ListOrdered}
|
||||
titleId="insights-pill-order-title"
|
||||
title={t("insights.pillOrder.title")}
|
||||
description={t("insights.pillOrder.description")}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draftTiles)}
|
||||
disabled={busy || !dirty}
|
||||
data-slot="insights-pill-order-save"
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("insights.pillOrder.save")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
* `connections-panel.tsx` so existing unit-test imports keep resolving.
|
||||
*/
|
||||
|
||||
import { Link2, Send } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
|
||||
@@ -38,21 +41,30 @@ export function IntegrationsSection() {
|
||||
// primary surface, with the delivery-channels group sequenced below it.
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<ConnectionsPanel />
|
||||
{/* Connections ("where the data comes from"). The OAuth + device data
|
||||
sources. A labelled group header gives this block parity with the
|
||||
delivery-channels group below it. */}
|
||||
<section className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.sections.integrations.connectionsHeading")}
|
||||
description={t(
|
||||
"settings.sections.integrations.connectionsDescription",
|
||||
)}
|
||||
/>
|
||||
<ConnectionsPanel />
|
||||
</section>
|
||||
|
||||
{/* Delivery channels ("where it's delivered"). The existing channels
|
||||
panel, unchanged. `id="channels"` anchors the group for the
|
||||
`/settings/channels` redirect; the per-channel anchors stay inside
|
||||
the panel. */}
|
||||
<section id="channels" className="scroll-mt-28 space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.integrations.channelsHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.channelsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Send}
|
||||
title={t("settings.sections.integrations.channelsHeading")}
|
||||
description={t("settings.sections.integrations.channelsDescription")}
|
||||
/>
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
|
||||
@@ -46,14 +49,13 @@ export function NotificationsSection() {
|
||||
account never shows an empty heading. */}
|
||||
{anyReminder ? (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.notifications.remindersHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.remindersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.sections.notifications.remindersHeading")}
|
||||
description={t(
|
||||
"settings.sections.notifications.remindersDescription",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Mood check-in reminder — only when the mood module is enabled. */}
|
||||
{moodEnabled ? (
|
||||
|
||||
Reference in new issue
Block a user