mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
refactor(settings): one header, one action row, one integration card
The sweep that follows from the primitives. Nothing here is new behaviour; every change removes a second way of saying something the surface already said one way somewhere else. Headers. The two collapsible security cards stop rebuilding `SettingsCardHeader` by hand to carry a chevron — the chevron rides inside the title button, and the description moves out of the collapsed region, so the first paint is no longer a bare title. The import card shell, the export and import section headings, and the four free-standing group headers stop inventing their own type scale: a heading above a GROUP of cards is `SectionHeading` (`text-base`), a card title is `SettingsCardHeader` (`text-lg`), and the step between them is what says which is which. The targets card gets a title instead of a naked icon. Actions. Every card-level row is `SettingsCardActions`. The primary action comes out of the header's status slot in six cards (password, web push, pillar order, coach memory, and the two danger-zone triggers) and out of the middle of the card in three more; the reset that hung in the dashboard-layout header joins save as a secondary in the same row. Result and status lines move ABOVE the row — sources' cross-link, the invite form's two status lines — because nothing follows a card's actions. Revoking a share link is `variant="destructive"` rather than an `outline` hand-tinted red in two different ways. The import row had four variants for four buttons; it has two. Export downloads are the card's primary, not an outline link. Integrations. One description sentence with the setup-guide link inline: the second paragraph was byte-identical across six of eight cards, which is the tell that it said nothing about the provider it sat under. The `<hr>` under the header — which existed only in this folder — is gone. So is the hint that pointed at a guide 40 px above it, and the second format for "not configured". Connect is one button everywhere: primary, `sm`, in the action row, instead of primary-and-small in three cards and outline-and-large in four. Plus the standards leftovers the audit listed: two `pl-5` list gutters, one `scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four hand-rolled callout shells that `SettingsInfoTile` already paints, and the two inline buttons that never had the mobile tap floor at all.
This commit is contained in:
1 parent
ce9b2814e6
commit
b2e1aa0cde
63 files changed
+720
-760
No files matched your search
@@ -64,7 +64,7 @@ function TruncatedCell({
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (S-03) — the revoked / isExpired / active
|
||||
* 3-way ternary is identical between the desktop table and the
|
||||
* mobile card list; the only difference is `text-xs` vs `text-[10px]`.
|
||||
* mobile card list; the only difference is `text-xs` vs `text-2xs`.
|
||||
* Centralise so the two surfaces stay in lock-step.
|
||||
*/
|
||||
function TokenStatusBadge({
|
||||
@@ -77,7 +77,7 @@ function TokenStatusBadge({
|
||||
const { t } = useTranslations();
|
||||
const expired =
|
||||
token.expiresAt != null && new Date(token.expiresAt) < new Date();
|
||||
const sizeClass = size === "xs" ? "text-[10px]" : "text-xs";
|
||||
const sizeClass = size === "xs" ? "text-2xs" : "text-xs";
|
||||
if (token.revoked) {
|
||||
return (
|
||||
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
|
||||
@@ -317,7 +317,7 @@ export function ApiTokenOverviewSection() {
|
||||
<Badge
|
||||
key={p}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate text-[10px]"
|
||||
className="text-2xs max-w-full truncate"
|
||||
title={
|
||||
p === "*"
|
||||
? t("admin.tokenPermissionAllTooltip")
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -130,7 +131,7 @@ export function GlitchtipSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -152,7 +153,7 @@ export function GlitchtipSection() {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -260,7 +261,7 @@ export function RemindersSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -315,7 +316,7 @@ export function RemindersSection() {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
|
||||
{testNotification.data?.results &&
|
||||
testNotification.data.results.length > 0 && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -131,7 +132,7 @@ export function UmamiSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -153,7 +154,7 @@ export function UmamiSection() {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { BellRing, KeyRound, Loader2 } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -191,7 +192,7 @@ export function WebPushVapidSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -217,7 +218,7 @@ export function WebPushVapidSection() {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
|
||||
<AlertDialog
|
||||
open={overwriteConfirmOpen}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
@@ -213,10 +214,7 @@ export function InsightsEditMode({
|
||||
const allHidden = draft.sections.every((s) => !s.visible);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="insights-edit-mode"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCard data-slot="insights-edit-mode">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div>
|
||||
<h2
|
||||
@@ -312,7 +310,7 @@ export function InsightsEditMode({
|
||||
{t("insights.editMode.manageInSettings")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* 3. Withings reauth: pill carries the "Error — reconnect" label.
|
||||
* 4. Mood Log disconnected: pill says "Not connected", no other
|
||||
* status display surfaces.
|
||||
* 5. Mood Log card has the visual divider that Withings always had
|
||||
* 5. no card paints a divider under its header
|
||||
* — consistency the maintainer explicitly called out.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
@@ -254,7 +254,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
expect(html).toContain("Withings activity error: 293");
|
||||
});
|
||||
|
||||
it("every integration card carries the visual divider (consistency)", () => {
|
||||
it("no integration card paints a divider under its header", () => {
|
||||
setIntegrationStatus({
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
@@ -274,10 +274,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Every integration card includes the section divider data-testid so the
|
||||
// header → body separation is visually consistent: the four legacy cards,
|
||||
// Apple Health, the three OAuth cards, and Nightscout.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(9);
|
||||
// The `<hr>` under the header existed only in `integrations/` — nine
|
||||
// cards carrying a rule no other Settings card has. The card's own gap
|
||||
// does the separating now, identically here and everywhere else.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
|
||||
});
|
||||
|
||||
// v1.17.1 — every integration card carries the same discreet "Setup guide"
|
||||
|
||||
@@ -43,7 +43,10 @@ const TONE_ICON: Record<SettingsInfoTileTone, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
};
|
||||
|
||||
export interface SettingsInfoTileProps {
|
||||
export interface SettingsInfoTileProps extends Omit<
|
||||
React.ComponentProps<"div">,
|
||||
"title"
|
||||
> {
|
||||
/** Lucide icon component; rendered in the tone colour. */
|
||||
icon: LucideIcon;
|
||||
/** Visual tone — routes through the semantic colour tokens. */
|
||||
@@ -62,6 +65,7 @@ export function SettingsInfoTile({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SettingsInfoTileProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -70,6 +74,7 @@ export function SettingsInfoTile({
|
||||
TONE_SURFACE[tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_ICON[tone])}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -301,18 +302,13 @@ export function AboutMeSection({
|
||||
</div>
|
||||
|
||||
{pendingQuestions.length > 0 && (
|
||||
<div
|
||||
<SettingsInfoTile
|
||||
icon={MessageCircleQuestion}
|
||||
tone="primary"
|
||||
data-testid="settings-about-me-questions"
|
||||
className="border-primary/30 bg-primary/5 rounded-lg border p-4"
|
||||
title={t("settings.ai.aboutMe.questionsTitle")}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<MessageCircleQuestion
|
||||
className="text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("settings.ai.aboutMe.questionsTitle")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
|
||||
<ul className="list-disc space-y-1 pl-6">
|
||||
{pendingQuestions.map((q) => (
|
||||
<li key={q}>{q}</li>
|
||||
))}
|
||||
@@ -325,7 +321,7 @@ export function AboutMeSection({
|
||||
{t("settings.ai.aboutMe.questionsOpenCoach")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsInfoTile>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -272,7 +273,7 @@ export function AboutSection() {
|
||||
title={t("settings.about.linksHeading")}
|
||||
description={t("settings.about.linksDescription")}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<SettingsCardActions align="start">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
@@ -317,7 +318,7 @@ export function AboutSection() {
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
|
||||
@@ -82,7 +82,10 @@ describe("grant invitation lapse date", () => {
|
||||
expect(onError).not.toContain("setIdentifier");
|
||||
expect(onError).not.toContain("setExpiresOn");
|
||||
expect(source).toContain('role="alert"');
|
||||
expect(source).toContain(
|
||||
// The submit stays inert while a name is missing or a request is in
|
||||
// flight. Matched whitespace-tolerantly: the predicate is the contract,
|
||||
// its line wrapping is not.
|
||||
expect(source.replace(/\s+/g, " ")).toContain(
|
||||
"invite.isPending || identifier.trim().length === 0",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -318,21 +319,6 @@ export function GrantInviteCard() {
|
||||
{t("recordSharing.invite.expiresHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="grant-invite-submit"
|
||||
// A narrowed invitation with nothing ticked would be a grant that
|
||||
// opens nothing, which the server refuses. The button is inert and
|
||||
// the sentence above says why, rather than sending a request whose
|
||||
// only purpose is to come back as an error.
|
||||
disabled={
|
||||
invite.isPending || identifier.trim().length === 0 || choice.blocked
|
||||
}
|
||||
>
|
||||
{invite.isPending
|
||||
? t("recordSharing.invite.submitting")
|
||||
: t("recordSharing.invite.submit")}
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
@@ -343,6 +329,27 @@ export function GrantInviteCard() {
|
||||
{t("recordSharing.invite.sent", { name: invited })}
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="grant-invite-submit"
|
||||
// A narrowed invitation with nothing ticked would be a grant that
|
||||
// opens nothing, which the server refuses. The button is inert and
|
||||
// the sentence above says why, rather than sending a request whose
|
||||
// only purpose is to come back as an error.
|
||||
disabled={
|
||||
invite.isPending ||
|
||||
identifier.trim().length === 0 ||
|
||||
choice.blocked
|
||||
}
|
||||
>
|
||||
{invite.isPending
|
||||
? t("recordSharing.invite.submitting")
|
||||
: t("recordSharing.invite.submit")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SeededFormDiscardDialog,
|
||||
@@ -555,7 +556,7 @@ export function AccountSection() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -568,7 +569,7 @@ export function AccountSection() {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -588,31 +589,26 @@ export function AccountSection() {
|
||||
reachable. */}
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Password card. v1.4.19 A6: action-button placement contract —
|
||||
on mobile (`<sm`) the action button stacks below the title +
|
||||
description and renders full-width; on desktop (`>=sm`) it
|
||||
right-aligns next to the title. The previous
|
||||
`flex items-center justify-between` pattern overflowed the
|
||||
card edge on Pixel 5 once button copy got longer than ~24 ch
|
||||
(the German "Passwort ändern" already pushes it; the tour
|
||||
card's "Restart onboarding tour" actually broke through the
|
||||
right border by ~48 px). */}
|
||||
{/* Password card. The action is this card's only one, so it sits in
|
||||
the card's action row like every other primary. It stays full-width
|
||||
under `sm` — the German "Passwort ändern" is long enough that a
|
||||
right-hugging button used to break through the card's right border
|
||||
on a narrow phone. */}
|
||||
<SettingsCard>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.25.7 — active-session management + the security-activity feed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -148,17 +150,14 @@ function ResearchModeCard() {
|
||||
/>
|
||||
|
||||
{showRePrompt && (
|
||||
<div
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="settings-research-mode-reprompt"
|
||||
className="border-warning/40 bg-warning/10 rounded-md border-l-4 px-3 py-2 text-sm"
|
||||
title={t("settings.researchMode.rePromptTitle")}
|
||||
>
|
||||
<p className="text-foreground font-medium">
|
||||
{t("settings.researchMode.rePromptTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.researchMode.rePromptBody")}
|
||||
</p>
|
||||
<p>{t("settings.researchMode.rePromptBody")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -168,7 +167,7 @@ function ResearchModeCard() {
|
||||
>
|
||||
{t("settings.researchMode.rePromptCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsInfoTile>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -262,64 +261,6 @@ function DataResetCard() {
|
||||
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"
|
||||
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
|
||||
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 && (
|
||||
@@ -330,6 +271,64 @@ function DataResetCard() {
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<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
|
||||
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>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -399,65 +398,6 @@ function AccountDeleteCard() {
|
||||
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"
|
||||
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
|
||||
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 && (
|
||||
@@ -468,6 +408,65 @@ function AccountDeleteCard() {
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<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
|
||||
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>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
@@ -147,58 +148,6 @@ export function CoachMemorySection({
|
||||
titleId="settings-ai-coach-memory-title"
|
||||
title={t("settings.ai.coachMemory.title")}
|
||||
description={t("settings.ai.coachMemory.description")}
|
||||
status={
|
||||
facts.length > 0 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{query.isError && (
|
||||
@@ -276,6 +225,58 @@ export function CoachMemorySection({
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.ai.coachMemory.summaryNote")}
|
||||
</p>
|
||||
<SettingsCardActions>
|
||||
{facts.length > 0 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -335,7 +336,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -352,7 +353,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
) : null}
|
||||
{t("insights.coach.settingsSave")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -441,28 +442,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<SettingsCard id={id} className="scroll-mt-28">
|
||||
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
|
||||
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. */}
|
||||
<SettingsCardHeader
|
||||
icon={LayoutDashboard}
|
||||
title={t("dashboard.customizeTitle")}
|
||||
status={
|
||||
<ConfirmButton
|
||||
slot="settings-dashboard-layout-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("dashboard.layoutReset")}
|
||||
title={t("dashboard.layoutResetTitle")}
|
||||
body={t("dashboard.layoutResetBody")}
|
||||
confirmLabel={t("dashboard.layoutResetConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{hydrated && layout && (
|
||||
<fieldset
|
||||
@@ -702,30 +684,47 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => layout && saveMutation.mutate(layout)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Reset is a secondary action, so it reads in the same row as save
|
||||
rather than from the header's status slot. */}
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="settings-dashboard-layout-reset"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("dashboard.layoutReset")}
|
||||
title={t("dashboard.layoutResetTitle")}
|
||||
body={t("dashboard.layoutResetBody")}
|
||||
confirmLabel={t("dashboard.layoutResetConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
/>
|
||||
{dirty && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => layout && saveMutation.mutate(layout)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</SettingsCardActions>
|
||||
|
||||
{!dirty && remote && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Mail } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -162,7 +163,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/settings/email/test"
|
||||
disabled={!settings?.enabled}
|
||||
@@ -177,7 +178,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function EnvironmentSection() {
|
||||
icon={MapPin}
|
||||
title={t("settings.sections.environment.home.title")}
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.home.description")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
@@ -271,7 +271,9 @@ export function EnvironmentSection() {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
|
||||
>
|
||||
{t("settings.sections.environment.search")}
|
||||
@@ -314,7 +316,7 @@ export function EnvironmentSection() {
|
||||
icon={Plane}
|
||||
title={t("settings.sections.environment.travel.title")}
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.travel.description")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
@@ -377,7 +379,9 @@ export function EnvironmentSection() {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={
|
||||
travelQuery.trim().length === 0 || searchTravel.isPending
|
||||
}
|
||||
@@ -439,6 +443,8 @@ export function EnvironmentSection() {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={travelStart > travelEnd || addTravel.isPending}
|
||||
onClick={() => addTravel.mutate()}
|
||||
>
|
||||
@@ -457,7 +463,7 @@ export function EnvironmentSection() {
|
||||
icon={CloudSun}
|
||||
title={t("settings.sections.environment.backfill.title")}
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.backfill.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
@@ -483,6 +489,8 @@ export function EnvironmentSection() {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={
|
||||
!home ||
|
||||
effectiveBackfillStart.length === 0 ||
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -80,14 +82,13 @@ export function ExportSection() {
|
||||
CSV/JSON data-out paths and the import surface. */}
|
||||
<section
|
||||
aria-labelledby="settings-section-export-other-title"
|
||||
className="space-y-3"
|
||||
className="space-y-4"
|
||||
>
|
||||
<h2
|
||||
<SectionHeading
|
||||
icon={Download}
|
||||
id="settings-section-export-other-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.export.otherOptionsHeading")}
|
||||
</h2>
|
||||
title={t("settings.sections.export.otherOptionsHeading")}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<MeasurementsCsvCard />
|
||||
<MedicationsCsvCard />
|
||||
@@ -113,7 +114,7 @@ interface ExportCardShellProps {
|
||||
description: string;
|
||||
format: ExportFormat;
|
||||
children?: React.ReactNode;
|
||||
/** Footer slot — typically the "Download" button + status text. */
|
||||
/** The card's action row — the "Download" button, and nothing after it. */
|
||||
footer: React.ReactNode;
|
||||
/** Extra grid-position classes applied to the outer card div. */
|
||||
outerClassName?: string;
|
||||
@@ -145,7 +146,7 @@ function ExportCardShell({
|
||||
}
|
||||
/>
|
||||
{children && <div className="space-y-3">{children}</div>}
|
||||
<div className="flex flex-wrap items-center gap-3">{footer}</div>
|
||||
<SettingsCardActions>{footer}</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -258,7 +259,6 @@ function CsvCard({
|
||||
footer={
|
||||
<Button
|
||||
data-testid={actionTestId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleDownload}
|
||||
@@ -346,7 +346,6 @@ function MedicationsCsvCard() {
|
||||
footer={
|
||||
<Button
|
||||
data-testid="export-action-medications-csv"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleDownload}
|
||||
@@ -471,7 +470,6 @@ function FullBackupCard() {
|
||||
footer={
|
||||
<Button
|
||||
data-testid="export-action-full-backup"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleDownload}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
import { Share2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
|
||||
export function GesundheitsakteSection() {
|
||||
@@ -42,10 +42,10 @@ export function GesundheitsakteSection() {
|
||||
<HealthRecordExportPanel />
|
||||
|
||||
<section id="sharing" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCardHeader
|
||||
<SectionHeading
|
||||
icon={Share2}
|
||||
title={t("settings.sections.sharing.title")}
|
||||
description={t("settings.sections.sharing.subtitle")}
|
||||
subtitle={t("settings.sections.sharing.subtitle")}
|
||||
/>
|
||||
<SharingSection />
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Loader2, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -308,7 +309,7 @@ export function AppleHealthImportCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<SettingsCardActions className="mt-auto" align="start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -325,7 +326,7 @@ export function AppleHealthImportCard() {
|
||||
)}
|
||||
{t("settings.sections.export.import.appleHealth.choose")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -170,7 +171,7 @@ export function CsvImportCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<SettingsCardActions className="mt-auto" align="start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -184,7 +185,7 @@ export function CsvImportCard() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={downloadExample}
|
||||
@@ -195,7 +196,7 @@ export function CsvImportCard() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
@@ -222,7 +223,7 @@ export function CsvImportCard() {
|
||||
)}
|
||||
{t("settings.sections.export.import.csv.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type IntakeImportResultState,
|
||||
} from "@/components/medications/intake-import-result";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
@@ -270,7 +271,7 @@ export function DoseHistoryImportCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<SettingsCardActions className="mt-auto" align="start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -284,7 +285,7 @@ export function DoseHistoryImportCard() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
@@ -311,7 +312,7 @@ export function DoseHistoryImportCard() {
|
||||
)}
|
||||
{t("settings.sections.export.import.doseHistory.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
export interface ImportCardShellProps {
|
||||
testId: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
@@ -17,14 +20,7 @@ export function ImportCardShell({
|
||||
}: ImportCardShellProps) {
|
||||
return (
|
||||
<SettingsCard data-testid={testId} className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
<SettingsCardHeader icon={Icon} title={title} description={description} />
|
||||
<div className="flex flex-1 flex-col gap-3">{children}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
* announce progress to assistive tech via `aria-live` regions.
|
||||
*/
|
||||
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { AppleHealthImportCard } from "./apple-health-import-card";
|
||||
import { JsonImportCard } from "./json-import-card";
|
||||
import { CsvImportCard } from "./csv-import-card";
|
||||
@@ -35,19 +38,14 @@ export function ImportPanel() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-import-title"
|
||||
className="space-y-3"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="settings-section-import-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.export.import.heading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.description")}
|
||||
</p>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Upload}
|
||||
id="settings-section-import-title"
|
||||
title={t("settings.sections.export.import.heading")}
|
||||
subtitle={t("settings.sections.export.import.description")}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<AppleHealthImportCard />
|
||||
<JsonImportCard />
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { AlertCircle, Download, FileJson, Loader2, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -196,7 +197,7 @@ export function JsonImportCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<SettingsCardActions className="mt-auto" align="start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -210,7 +211,7 @@ export function JsonImportCard() {
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
@@ -238,7 +239,7 @@ export function JsonImportCard() {
|
||||
)}
|
||||
{t("settings.sections.export.import.json.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import { useMemo } from "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";
|
||||
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { type InsightsSectionId } from "@/lib/insights-layout";
|
||||
|
||||
@@ -51,11 +51,11 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
aria-labelledby="insights-overview-arrange-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
<SectionHeading
|
||||
icon={LayoutGrid}
|
||||
titleId="insights-overview-arrange-title"
|
||||
id="insights-overview-arrange-title"
|
||||
title={t("insights.settings.overviewTitle")}
|
||||
description={t("insights.settings.overviewDescription")}
|
||||
subtitle={t("insights.settings.overviewDescription")}
|
||||
/>
|
||||
|
||||
{/* Gate the editor mount until the layout GET settles. The editor seeds
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -263,20 +264,6 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
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 ? (
|
||||
@@ -350,6 +337,20 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<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>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ import Link from "next/link";
|
||||
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 { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -55,12 +55,9 @@ export function IntegrationsSection() {
|
||||
sources. A labelled group header gives this block parity with the
|
||||
delivery-channels group below it. */}
|
||||
<section className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
<SectionHeading
|
||||
icon={Link2}
|
||||
title={t("settings.sections.integrations.connectionsHeading")}
|
||||
description={t(
|
||||
"settings.sections.integrations.connectionsDescription",
|
||||
)}
|
||||
/>
|
||||
<ConnectionsPanel />
|
||||
|
||||
@@ -79,10 +76,10 @@ export function IntegrationsSection() {
|
||||
`/settings/channels` redirect; the per-channel anchors stay inside
|
||||
the panel. */}
|
||||
<section id="channels" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCardHeader
|
||||
<SectionHeading
|
||||
icon={Send}
|
||||
title={t("settings.sections.integrations.channelsHeading")}
|
||||
description={t("settings.sections.integrations.channelsDescription")}
|
||||
subtitle={t("settings.sections.integrations.channelsDescription")}
|
||||
/>
|
||||
{/* Names the per-event notification matrix so the three notification
|
||||
surfaces (matrix, reminder types, channels) cross-reference. */}
|
||||
|
||||
@@ -146,12 +146,12 @@ describe("<AppleHealthCard>", () => {
|
||||
expect(html).toContain("quiet");
|
||||
});
|
||||
|
||||
it("carries the shared card divider like every sibling", () => {
|
||||
it("carries no card divider — the card gap does that job", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
expect(render()).toContain('data-testid="integration-card-divider"');
|
||||
expect(render()).not.toContain('data-testid="integration-card-divider"');
|
||||
});
|
||||
|
||||
it("links to the existing one-shot Apple Health import fallback", () => {
|
||||
|
||||
@@ -80,13 +80,13 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
|
||||
expect(html).toContain('href="/insights/blood-glucose"');
|
||||
});
|
||||
|
||||
it("carries the shared card divider like every sibling", () => {
|
||||
it("carries no card divider — the card gap does that job", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
});
|
||||
expect(html).toContain('data-testid="integration-card-divider"');
|
||||
expect(html).not.toContain('data-testid="integration-card-divider"');
|
||||
});
|
||||
|
||||
it("renders the per-metric freshness disclosure with a stale row", () => {
|
||||
|
||||
@@ -160,8 +160,6 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{isError && (
|
||||
<IntegrationErrorMessage
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -243,8 +244,6 @@ export function FitbitCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
@@ -360,10 +359,7 @@ export function FitbitCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -380,7 +376,7 @@ export function FitbitCard({
|
||||
)}
|
||||
{t("settings.fitbitSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -503,19 +499,26 @@ export function FitbitCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/fitbit/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.fitbitConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="fitbit-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/fitbit/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.fitbitConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.fitbitNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -415,8 +416,6 @@ export function GoogleHealthCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
@@ -553,10 +552,7 @@ export function GoogleHealthCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -573,7 +569,7 @@ export function GoogleHealthCard({
|
||||
)}
|
||||
{t("settings.googleHealthSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -740,19 +736,26 @@ export function GoogleHealthCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.googleHealthConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="googlehealth-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.googleHealthConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.googleHealthNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -216,8 +217,6 @@ export function NightscoutCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{operatorApprovalRequired && (
|
||||
@@ -323,7 +322,7 @@ export function NightscoutCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -342,7 +341,7 @@ export function NightscoutCard({
|
||||
? t("settings.nightscoutUpdate")
|
||||
: t("settings.nightscoutConnect")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -285,8 +286,6 @@ export function OAuthProviderCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div data-testid={`${provider}-error`}>
|
||||
@@ -387,10 +386,7 @@ export function OAuthProviderCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -407,7 +403,7 @@ export function OAuthProviderCard({
|
||||
)}
|
||||
{t(`${i18nPrefix}SaveCredentials`)}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -505,17 +501,19 @@ export function OAuthProviderCard({
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={serverUnavailable}
|
||||
onClick={handleConnect}
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={serverUnavailable}
|
||||
onClick={handleConnect}
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
|
||||
@@ -102,11 +102,14 @@ export function IntegrationRedirectGuide({
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 (Wave F) — the one description skeleton every integration card paints:
|
||||
* a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary
|
||||
* sentence (`${i18nPrefix}DescriptionSecondary`) that ends with the setup-guide
|
||||
* link rendered INLINE at the very end of the grey copy — not a standalone
|
||||
* bottom-of-card element. Single-sourced so all six cards read as one family.
|
||||
* The one description every integration card paints: ONE sentence, then the
|
||||
* setup-guide link inline at the end of it.
|
||||
*
|
||||
* It used to be two paragraphs stacked in the description slot — a white
|
||||
* primary sentence and a grey secondary one whose only job was to carry the
|
||||
* link. The secondary sentence was byte-identical across six of the eight
|
||||
* cards, which is the tell: it said nothing about the provider it sat under.
|
||||
* The link is the part that carried information, so the link is what stayed.
|
||||
*/
|
||||
export function IntegrationCardDescription({
|
||||
i18nPrefix,
|
||||
@@ -118,21 +121,18 @@ export function IntegrationCardDescription({
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-foreground">{t(`${i18nPrefix}Description`)}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(`${i18nPrefix}DescriptionSecondary`)}{" "}
|
||||
<a
|
||||
href={integrationDocsHref(provider)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid={`${provider}-setup-guide`}
|
||||
data-slot="integration-setup-guide"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.integrationSetupGuide")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
{t(`${i18nPrefix}Description`)}{" "}
|
||||
<a
|
||||
href={integrationDocsHref(provider)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid={`${provider}-setup-guide`}
|
||||
data-slot="integration-setup-guide"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.integrationSetupGuide")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -224,8 +225,6 @@ export function WhoopCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
@@ -342,10 +341,7 @@ export function WhoopCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -362,7 +358,7 @@ export function WhoopCard({
|
||||
)}
|
||||
{t("settings.whoopSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -485,19 +481,26 @@ export function WhoopCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/whoop/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.whoopConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="whoop-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/whoop/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.whoopConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.whoopNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -236,8 +237,6 @@ export function WithingsCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
|
||||
@@ -390,10 +389,7 @@ export function WithingsCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -410,7 +406,7 @@ export function WithingsCard({
|
||||
)}
|
||||
{t("settings.withingsSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -534,19 +530,26 @@ export function WithingsCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/withings/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.withingsConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="withings-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/withings/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.withingsConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.withingsNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -219,7 +219,7 @@ export function LowStockCard({
|
||||
// (`/settings/notifications#low-stock`) — keep it stable.
|
||||
id="low-stock"
|
||||
aria-labelledby="settings-low-stock-title"
|
||||
className="scroll-mt-20"
|
||||
className="scroll-mt-28"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={PackageOpen}
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -46,35 +46,14 @@ export function NotificationsSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Cross-links to the other two notification surfaces so this screen
|
||||
names them — the per-event matrix and the delivery channels. */}
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
data-slot="notifications-crosslinks"
|
||||
>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/notifications">
|
||||
{t("settings.sections.notifications.matrixLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/integrations#channels">
|
||||
{t("settings.sections.notifications.channelsLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
|
||||
least one reminder-type card is visible, so a fully-disabled
|
||||
account never shows an empty heading. */}
|
||||
{anyReminder ? (
|
||||
<section className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
<SectionHeading
|
||||
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. */}
|
||||
@@ -93,6 +72,24 @@ export function NotificationsSection() {
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{/* Cross-links to the other two notification surfaces. At the END of
|
||||
the page: a screen that opens with two ways off it never says what
|
||||
it is for. */}
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
data-slot="notifications-crosslinks"
|
||||
>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/notifications">
|
||||
{t("settings.sections.notifications.matrixLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/integrations#channels">
|
||||
{t("settings.sections.notifications.channelsLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -192,7 +193,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/settings/ntfy/test"
|
||||
disabled={!settings?.enabled}
|
||||
@@ -207,7 +208,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -157,7 +158,7 @@ export function PrivacySection() {
|
||||
title={t("settings.privacy.export.title")}
|
||||
description={t("settings.privacy.export.description")}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/export">
|
||||
{t("settings.privacy.export.openExport")}
|
||||
@@ -168,7 +169,7 @@ export function PrivacySection() {
|
||||
{t("settings.privacy.export.openHealthRecord")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Delete / reset — with the honest backup lag disclosure */}
|
||||
@@ -178,16 +179,16 @@ export function PrivacySection() {
|
||||
title={t("settings.privacy.delete.title")}
|
||||
description={t("settings.privacy.delete.description")}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<SettingsInfoTile icon={Info} tone="warning">
|
||||
{t("settings.privacy.delete.backupLag")}
|
||||
</SettingsInfoTile>
|
||||
<SettingsInfoTile icon={Info} tone="warning">
|
||||
{t("settings.privacy.delete.backupLag")}
|
||||
</SettingsInfoTile>
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/advanced">
|
||||
{t("settings.privacy.delete.openAdvanced")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Privacy posture (AI privacy mode + research mode live on their pages) */}
|
||||
@@ -197,7 +198,7 @@ export function PrivacySection() {
|
||||
title={t("settings.privacy.posture.title")}
|
||||
description={t("settings.privacy.posture.description")}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SettingsCardActions align="start">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/ai">
|
||||
{t("settings.privacy.posture.openAi")}
|
||||
@@ -208,7 +209,7 @@ export function PrivacySection() {
|
||||
{t("settings.privacy.posture.openResearch")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Active sessions + trusted devices + security activity (embedded) */}
|
||||
|
||||
@@ -50,6 +50,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Gauge, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -443,7 +444,7 @@ export function ScoreSection() {
|
||||
|
||||
{/* Bottom-right, in its own row, like every other settings card
|
||||
that saves a form. */}
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -460,7 +461,7 @@ export function ScoreSection() {
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -72,36 +73,30 @@ export function SecurityActivityCard({
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-activity-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="settings-security-activity-toggle"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ShieldCheck
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="settings-security-activity-toggle"
|
||||
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("settings.security.activityTitle")}
|
||||
</h2>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
description={t("settings.security.activityDescription")}
|
||||
/>
|
||||
<div id={regionId} hidden={!open} className="space-y-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.activityDescription")}
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PasskeyUpgradeNudge() {
|
||||
|
||||
return (
|
||||
<div className="border-primary/30 bg-primary/5 flex items-start gap-3 rounded-lg border p-4">
|
||||
<Sparkles className="text-primary mt-0.5 h-5 w-5 shrink-0" />
|
||||
<Sparkles className="text-muted-foreground mt-0.5 size-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.security.passkeyNudge.title")}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -231,7 +232,7 @@ export function TotpCard({
|
||||
{/* ── Enable wizard ── */}
|
||||
{setup && (
|
||||
<div className="space-y-4">
|
||||
<ol className="text-muted-foreground list-decimal space-y-3 pl-5 text-sm">
|
||||
<ol className="text-muted-foreground list-decimal space-y-3 pl-6 text-sm">
|
||||
<li>{t("settings.security.totp.step1")}</li>
|
||||
<li>{t("settings.security.totp.step2")}</li>
|
||||
</ol>
|
||||
@@ -326,7 +327,7 @@ export function TotpCard({
|
||||
{freshCodes && (
|
||||
<div>
|
||||
<RecoveryCodesPanel codes={freshCodes} />
|
||||
<div className="mt-3 flex justify-end">
|
||||
<SettingsCardActions className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -335,7 +336,7 @@ export function TotpCard({
|
||||
>
|
||||
{t("settings.security.recovery.savedIt")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -86,36 +88,30 @@ export function SecuritySessionsCard({
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-sessions-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="settings-security-sessions-toggle"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<MonitorSmartphone
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<SettingsCardHeader
|
||||
icon={MonitorSmartphone}
|
||||
title={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="settings-security-sessions-toggle"
|
||||
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("settings.security.sessionsTitle")}
|
||||
</h2>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
description={t("settings.security.sessionsDescription")}
|
||||
/>
|
||||
<div id={regionId} hidden={!open} className="space-y-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.sessionsDescription")}
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
@@ -188,7 +184,7 @@ export function SecuritySessionsCard({
|
||||
)}
|
||||
|
||||
{hasOthers && (
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="sign-out-everywhere"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -199,7 +195,7 @@ export function SecuritySessionsCard({
|
||||
onConfirm={() => revokeOthers.mutate()}
|
||||
pending={revokeOthers.isPending}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -453,22 +454,25 @@ export function ShareLinkCreateForm({
|
||||
a page with nothing on it. The scope line above says so and says
|
||||
what to do about it, and describes the button so the disabled state
|
||||
is not silent for a screen reader. */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!label.trim() ||
|
||||
(!documentOnly && selectedLeaves.size === 0)
|
||||
}
|
||||
aria-describedby={documentOnly ? undefined : scopeSummaryId}
|
||||
data-testid="share-create-submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sharing.create")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!label.trim() ||
|
||||
(!documentOnly && selectedLeaves.size === 0)
|
||||
}
|
||||
aria-describedby={documentOnly ? undefined : scopeSummaryId}
|
||||
data-testid="share-create-submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sharing.create")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
|
||||
<ShareDocumentPicker
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileText, KeyRound, Share2, Trash2 } from "lucide-react";
|
||||
import { FileText, KeyRound, Link2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -134,7 +134,7 @@ function ShareLinksCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Share2}
|
||||
icon={Link2}
|
||||
title={t("settings.sharing.createTitle")}
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
@@ -220,9 +220,9 @@ function ShareLinksCard() {
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full sm:min-h-9"
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.revoke")}
|
||||
@@ -242,7 +242,6 @@ function ShareLinksCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -548,35 +549,9 @@ export function SourcesSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary
|
||||
action stacks on top on mobile, row order stays Cancel → Save
|
||||
on `sm+`. */}
|
||||
{dirty && priority && (
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(priority)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the
|
||||
sources this ladder ranks are connected / removed. Folded into
|
||||
the card so the page top stays the standard heading + subtitle. */}
|
||||
sources this ladder ranks are connected / removed. Above the
|
||||
action row: nothing follows a card's actions. */}
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.sections.sources.integrationsHint")}{" "}
|
||||
<Link
|
||||
@@ -587,6 +562,33 @@ export function SourcesSection() {
|
||||
{t("settings.sections.sources.integrationsHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{dirty && priority && (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate(priority)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MessageCircle, Save } from "lucide-react";
|
||||
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -190,11 +192,13 @@ export function TelegramCard({
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-xs">
|
||||
<p>{t("settings.telegramStep1")}</p>
|
||||
<p>{t("settings.telegramStep2")}</p>
|
||||
<p>{t("settings.telegramStep3")}</p>
|
||||
</div>
|
||||
<SettingsInfoTile icon={Info} tone="neutral">
|
||||
<ol className="list-decimal space-y-1 pl-4">
|
||||
<li>{t("settings.telegramStep1")}</li>
|
||||
<li>{t("settings.telegramStep2")}</li>
|
||||
<li>{t("settings.telegramStep3")}</li>
|
||||
</ol>
|
||||
</SettingsInfoTile>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
@@ -207,7 +211,7 @@ export function TelegramCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/settings/telegram/test"
|
||||
disabled={!settings?.hasBotToken}
|
||||
@@ -224,7 +228,7 @@ export function TelegramCard({
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { toast } from "sonner";
|
||||
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -120,31 +122,14 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<SettingsCard id={id} className="scroll-mt-28">
|
||||
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
|
||||
already provides the title + description for this route, so the
|
||||
card-level title + subtitle were a duplicate of the page header.
|
||||
Keep the icon + reset action so the card still has a visible
|
||||
control affordance. */}
|
||||
<div className="flex items-center justify-between">
|
||||
<SlidersHorizontal
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{data && Object.keys(data.overrides).length > 0 && (
|
||||
<ConfirmButton
|
||||
slot="settings-thresholds-reset-all"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("thresholds.resetAllAction")}
|
||||
title={t("thresholds.resetAllTitle")}
|
||||
body={t("thresholds.resetAllBody")}
|
||||
confirmLabel={t("thresholds.resetAllConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* The card carries a real title. It used to render a naked icon and
|
||||
a reset button, on the grounds that the page header already said
|
||||
the same thing — which left one card in the tree with no heading
|
||||
at all. The page subtitle is the one that goes. */}
|
||||
<SettingsCardHeader
|
||||
icon={SlidersHorizontal}
|
||||
title={t("settings.sections.thresholds.title")}
|
||||
/>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<ThresholdsSkeletonList />
|
||||
@@ -163,6 +148,23 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && Object.keys(data.overrides).length > 0 ? (
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="settings-thresholds-reset-all"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("thresholds.resetAllAction")}
|
||||
title={t("thresholds.resetAllTitle")}
|
||||
body={t("thresholds.resetAllBody")}
|
||||
confirmLabel={t("thresholds.resetAllConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate(null)}
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -148,7 +149,7 @@ export function TrustedDevicesCard({
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="revoke-all-trusted-devices"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -161,7 +162,7 @@ export function TrustedDevicesCard({
|
||||
onConfirm={() => revokeAll.mutate()}
|
||||
pending={revokeAll.isPending}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BellRing, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
@@ -161,17 +162,20 @@ export function WebPushCard() {
|
||||
}
|
||||
}
|
||||
|
||||
// The primary action sits in the header row (right of the title) rather than
|
||||
// bottom-left, so an inactive card does not waste a wide empty band before
|
||||
// its enable button. The loading spinner rides the same slot; the
|
||||
// unsupported / denied notices stay in the body.
|
||||
const headerAction = loading ? (
|
||||
// The subscribe / unsubscribe control is this card's primary action, so it
|
||||
// sits where every other card's does: the bottom action row. It used to ride
|
||||
// the header status slot as an `outline`, which made the one card in the
|
||||
// channel group that carries an enable button look like the one card that
|
||||
// carries a status badge.
|
||||
const cardAction = loading ? (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : !isSupported || isDenied ? null : (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
{isSubscribed && (
|
||||
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
|
||||
)}
|
||||
{isSubscribed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={handleUnsubscribe}
|
||||
@@ -184,7 +188,6 @@ export function WebPushCard() {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={handleSubscribe}
|
||||
@@ -198,10 +201,7 @@ export function WebPushCard() {
|
||||
{t("settings.webPushSubscribe")}
|
||||
</Button>
|
||||
)}
|
||||
{isSubscribed && (
|
||||
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
|
||||
)}
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -210,7 +210,6 @@ export function WebPushCard() {
|
||||
icon={BellRing}
|
||||
title={t("settings.webPush")}
|
||||
description={t("settings.webPushDescription")}
|
||||
status={headerAction}
|
||||
/>
|
||||
|
||||
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
|
||||
@@ -235,6 +234,8 @@ export function WebPushCard() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cardAction}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Webhook } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -204,7 +205,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-end gap-2">
|
||||
<SettingsCardActions>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/settings/webhook/test"
|
||||
disabled={!settings?.enabled}
|
||||
@@ -219,7 +220,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
Reference in new issue
Block a user