refactor(settings): unify card headers, integration verbs, test buttons, and tap targets

Holistic consistency pass over the settings and integration surfaces so the
same concept reads and behaves the same way everywhere.

- Card headers: migrate the four integration cards (Withings, WHOOP,
  Fitbit/Google Health, Mood Log) plus the profile, passkeys, avatar, about,
  api-endpoints, api-tokens, and sharing cards onto the shared
  SettingsCardHeader primitive. Extend the primitive with an inline title
  accessory slot (Fitbit tag + experimental badge) and a multi-line
  description slot (overlap / experimental / deprecation sub-notes), keeping
  the rendered result identical.
- Integration verbs: normalise Mood Log onto the canonical "Sync now" /
  "Sync all data" / "Synchronize" set used by the other integrations, across
  all six locales. Swap the full-sync icon from Download to RefreshCw and
  align its action-row icon metrics with the sibling cards.
- Test buttons: migrate Telegram and ntfy off their hand-rolled "Test
  message" buttons onto the shared TestConnectionButton, so every channel and
  integration test control shares one icon, label, latency readout, and error
  taxonomy. Retire the now-unused testMessage / testSent strings.
- ntfy auth-token placeholder no longer reuses the "Saved" status string;
  it now reads "Saved — enter new to replace" like the other secret fields.
- Save-success copy: collapse the moodLog and Telegram bespoke strings onto
  the shared settings.saved string.
- Tap targets: floor every size="sm" action-row button (sync / full-sync /
  test / disconnect / save / connect-secret copy) at min-h-11, fixed at the
  TestConnectionButton source and across the cards, so they clear the 44px
  touch minimum.
- Contrast: the onboarding source-card badge moves from muted-on-muted to
  text-foreground; web-push card body rhythm aligns on space-y-4; Mood Log's
  deprecation note drops its bespoke 11px-italic treatment for the shared
  sub-note style.

(cherry picked from commit 11931d14b7875b7e2184a3b373d7a34b842f8eae)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 03:45:04 +02:00
1 parent b64f48bce3
commit 3ce0b69802
17 files changed
+248 -279

No files matched your search

+1 -1
View File
@@ -227,7 +227,7 @@ function SourceCardItem({
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
{badgeLabel ? (
<span
className="bg-muted text-muted-foreground mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
className="bg-muted text-foreground mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
data-testid={`source-badge-${card.slug}`}
>
{badgeLabel}
+14 -5
View File
@@ -35,14 +35,19 @@ import { cn } from "@/lib/utils";
export interface SettingsCardHeaderProps {
/** Lucide icon component; rendered as `text-primary h-5 w-5`. */
icon: LucideIcon;
/** Card title — rendered as `<h2 class="text-lg font-semibold">`. */
title: string;
/** Card title — rendered as `<h2 class="text-lg font-semibold">`.
* Accepts a node so a card can wrap the title in a link. */
title: React.ReactNode;
/** Optional id for the `<h2>` so an outer `aria-labelledby`
* attribute on the card itself can reference it. */
titleId?: string;
/** Optional inline accessory rendered in the title row, immediately
* after the title (e.g. a tag chip + experimental badge). The title
* row wraps so the accessories reflow below on a narrow viewport. */
titleAccessory?: React.ReactNode;
/** Optional short description rendered as muted text below the
* title row. Either a string (paragraph) or arbitrary node
* (links, badges, etc.). */
* (links, badges, extra sub-notes, etc.). */
description?: React.ReactNode;
/** Optional right-aligned status surface — typically an
* `<IntegrationStatusPill>` or a wrapper around badges. */
@@ -55,6 +60,7 @@ export function SettingsCardHeader({
icon: Icon,
title,
titleId,
titleAccessory,
description,
status,
className,
@@ -62,16 +68,19 @@ export function SettingsCardHeader({
return (
<header className={cn("space-y-1", className)}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<h2 id={titleId} className="text-lg font-semibold">
{title}
</h2>
{titleAccessory}
</div>
{status ? <div className="flex shrink-0 items-center gap-2">{status}</div> : null}
</div>
{description ? (
<p className="text-muted-foreground text-xs">{description}</p>
<div className="text-muted-foreground space-y-1 text-xs">
{description}
</div>
) : null}
</header>
);
+7 -10
View File
@@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
@@ -251,10 +252,7 @@ export function AboutSection() {
which read as a separate field even though the two values belong
on the same row. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<Info className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">HealthLog</h2>
</div>
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
{isLoading || !version ? (
<div className="flex items-center gap-2">
@@ -324,12 +322,11 @@ export function AboutSection() {
telling the user what the section was for. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<BookOpen className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.linksHeading")}
</h2>
</div>
<SettingsCardHeader
icon={BookOpen}
title={t("settings.about.linksHeading")}
className="mb-4"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button asChild variant="outline" size="sm">
<a
+16 -12
View File
@@ -52,6 +52,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
@@ -398,10 +399,11 @@ export function AccountSection() {
<AvatarSection />
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<User className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
</div>
<SettingsCardHeader
icon={User}
title={t("settings.profile")}
className="mb-4"
/>
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -600,10 +602,11 @@ export function AccountSection() {
{/* Passkeys card */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.passkeys")}</h2>
</div>
<SettingsCardHeader
icon={Shield}
title={t("settings.passkeys")}
className="mb-4"
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<div className="mt-4 flex justify-end">
<Button
@@ -1131,10 +1134,11 @@ function AvatarSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<ImageUp className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.avatar.title")}</h2>
</div>
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
className="mb-4"
/>
<p className="text-muted-foreground mb-4 text-sm">
{t("settings.avatar.description")}
</p>
+26 -31
View File
@@ -19,6 +19,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -77,15 +78,11 @@ function ApiEndpointsCard() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.apiEndpointsTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.apiEndpointsDescription")}
</p>
<SettingsCardHeader
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
@@ -244,28 +241,26 @@ function ApiTokensCard() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.apiTokens")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{activeTokens.length > 0 && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{latestActiveUse && (
<Badge variant="outline" className="text-xs">
{t("settings.tokenTableLastUsed")}:{" "}
{formatDateTime(latestActiveUse)}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.apiTokensDescription")}
</p>
<SettingsCardHeader
icon={Key}
title={t("settings.apiTokens")}
description={t("settings.apiTokensDescription")}
status={
<>
{activeTokens.length > 0 && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{latestActiveUse && (
<Badge variant="outline" className="text-xs">
{t("settings.tokenTableLastUsed")}:{" "}
{formatDateTime(latestActiveUse)}
</Badge>
)}
</>
}
/>
<div className="mt-4 space-y-4">
<form onSubmit={handleCreate} className="flex gap-2">
+134 -98
View File
@@ -5,7 +5,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertCircle,
Download,
HeartPulse,
Link2,
Loader2,
@@ -32,6 +31,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { useAuth } from "@/hooks/use-auth";
@@ -379,16 +379,14 @@ function WithingsCard({
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Link2 className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.withings")}</h2>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.withingsDescription")}
</p>
<SettingsCardHeader
icon={Link2}
title={t("settings.withings")}
description={t("settings.withingsDescription")}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
}
/>
<hr
data-testid="integration-card-divider"
@@ -539,7 +537,7 @@ function WithingsCard({
type="submit"
variant="outline"
size="sm"
className="w-full sm:w-auto"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
@@ -575,6 +573,7 @@ function WithingsCard({
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
@@ -587,7 +586,12 @@ function WithingsCard({
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -622,7 +626,7 @@ function WithingsCard({
<Button
variant="outline"
size="sm"
className="text-destructive"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.withingsDisconnect")}
@@ -820,19 +824,21 @@ function WhoopCard({
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.whoop")}</h2>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.whoopDescription")}
</p>
<p className="text-muted-foreground/80 mt-2 text-xs">
{t("settings.whoopOverlapNote")}
</p>
<SettingsCardHeader
icon={Activity}
title={t("settings.whoop")}
description={
<>
<p>{t("settings.whoopDescription")}</p>
<p className="text-muted-foreground/80">
{t("settings.whoopOverlapNote")}
</p>
</>
}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
}
/>
<hr
data-testid="integration-card-divider"
@@ -950,7 +956,7 @@ function WhoopCard({
type="submit"
variant="outline"
size="sm"
className="w-full sm:w-auto"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
@@ -980,6 +986,7 @@ function WhoopCard({
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
@@ -992,7 +999,12 @@ function WhoopCard({
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -1027,7 +1039,7 @@ function WhoopCard({
<Button
variant="outline"
size="sm"
className="text-destructive"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.whoopDisconnect")}
@@ -1239,35 +1251,41 @@ function FitbitCard({
data-testid="fitbit-card"
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<HeartPulse className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.fitbit")}</h2>
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.fitbitTag")}
</span>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.fitbitDescription")}
</p>
<p
data-testid="fitbit-experimental-note"
className="text-muted-foreground/80 mt-2 text-xs"
>
{t("settings.fitbitExperimentalNote")}
</p>
<p className="text-muted-foreground/80 mt-2 text-xs">
{t("settings.fitbitOverlapNote")}
</p>
<SettingsCardHeader
icon={HeartPulse}
title={t("settings.fitbit")}
titleAccessory={
<>
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.fitbitTag")}
</span>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</>
}
description={
<>
<p>{t("settings.fitbitDescription")}</p>
<p
data-testid="fitbit-experimental-note"
className="text-muted-foreground/80"
>
{t("settings.fitbitExperimentalNote")}
</p>
<p className="text-muted-foreground/80">
{t("settings.fitbitOverlapNote")}
</p>
</>
}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
}
/>
<hr
data-testid="integration-card-divider"
@@ -1384,7 +1402,7 @@ function FitbitCard({
type="submit"
variant="outline"
size="sm"
className="w-full sm:w-auto"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
@@ -1414,6 +1432,7 @@ function FitbitCard({
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
@@ -1426,7 +1445,12 @@ function FitbitCard({
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -1461,7 +1485,7 @@ function FitbitCard({
<Button
variant="outline"
size="sm"
className="text-destructive"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.fitbitDisconnect")}
@@ -1556,7 +1580,7 @@ function MoodLogCard({
body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }),
});
if (res.ok) {
setMsg(t("settings.moodLogSaved"));
setMsg(t("settings.saved"));
setMsgType("success");
setUrl("");
setApiKey("");
@@ -1581,10 +1605,7 @@ function MoodLogCard({
if (res.ok) {
const json = await res.json();
setMsg(
t("settings.moodLogSyncResult").replace(
"{count}",
String(json.data.imported),
),
t("settings.moodLogSyncResult", { count: json.data.imported }),
);
setMsgType("success");
void refreshStatus();
@@ -1622,28 +1643,30 @@ function MoodLogCard({
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Smile className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
<a
href="https://moodlog.onback.io"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{t("settings.moodLogTitle")}
</a>
</h2>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.moodLogDescription")}
</p>
<p className="text-muted-foreground/80 mt-1 text-[11px] italic">
{t("settings.moodLogDeprecated")}
</p>
<SettingsCardHeader
icon={Smile}
title={
<a
href="https://moodlog.onback.io"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{t("settings.moodLogTitle")}
</a>
}
description={
<>
<p>{t("settings.moodLogDescription")}</p>
<p className="text-muted-foreground/80">
{t("settings.moodLogDeprecated")}
</p>
</>
}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
}
/>
{/* v1.4.19 A5 — visual divider matches Withings for consistency
(the maintainer explicitly called the asymmetry out). */}
@@ -1704,9 +1727,13 @@ function MoodLogCard({
type="submit"
disabled={saving || (!url.trim() && !apiKey.trim())}
size="sm"
className="min-h-11"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<Save className="mr-2 h-4 w-4" />
{saving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("common.save")}
</Button>
</div>
@@ -1740,7 +1767,7 @@ function MoodLogCard({
setMsg(t("common.copied"));
setMsgType("success");
}}
className="w-full sm:w-auto"
className="min-h-11 w-full sm:w-auto"
>
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
common.copy key instead of stripping the
@@ -1778,18 +1805,27 @@ function MoodLogCard({
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
onClick={() => handleSync(false)}
>
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<RefreshCw className="mr-2 h-4 w-4" />
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.moodLogSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
<Download className="mr-2 h-4 w-4" />
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("settings.moodLogFullSync")}
</Button>
</AlertDialogTrigger>
@@ -1821,9 +1857,9 @@ function MoodLogCard({
<Button
variant="outline"
size="sm"
className="text-destructive"
className="text-destructive min-h-11"
>
<Unlink className="mr-2 h-4 w-4" />
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.moodLogDisconnect")}
</Button>
</AlertDialogTrigger>
+11 -33
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Loader2, Send } from "lucide-react";
import { Bell, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -82,24 +83,6 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const test = useMutation({
mutationFn: async () => {
const res = await fetch("/api/settings/ntfy/test", { method: "POST" });
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
}
},
onSuccess: () => {
setSaveMsg(t("settings.testSent"));
setSaveMsgType("success");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
return (
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
@@ -151,7 +134,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
id="ntfy-auth"
placeholder={
settings?.hasAuthToken
? t("settings.saved")
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={authToken}
@@ -172,21 +155,16 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</p>
)}
<div className="flex justify-end gap-2">
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/ntfy/test"
disabled={!settings?.enabled}
/>
<Button
type="button"
variant="outline"
disabled={test.isPending || !settings?.enabled}
onClick={() => test.mutate()}
type="submit"
disabled={save.isPending}
className="min-h-11"
>
{test.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Send className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.testMessage")}
</Button>
<Button type="submit" disabled={save.isPending}>
{save.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
+6 -9
View File
@@ -37,6 +37,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -214,15 +215,11 @@ function ShareLinksCard() {
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Share2 className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.sharing.createTitle")}
</h2>
</div>
<p className="text-muted-foreground -mt-4 text-xs">
{t("settings.sharing.createDescription")}
</p>
<SettingsCardHeader
icon={Share2}
title={t("settings.sharing.createTitle")}
description={t("settings.sharing.createDescription")}
/>
<form
className="space-y-4"
+11 -37
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, MessageCircle, Save, Send } from "lucide-react";
import { Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -30,7 +31,6 @@ export function TelegramCard({
const [chatId, setChatId] = useState("");
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
@@ -76,7 +76,7 @@ export function TelegramCard({
});
if (res.ok) {
setMsg(t("settings.telegramSaved"));
setMsg(t("settings.saved"));
setMsgType("success");
setBotToken("");
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
@@ -88,23 +88,6 @@ export function TelegramCard({
setSaving(false);
}
async function handleTest() {
setTesting(true);
setMsg(null);
setMsgType(null);
const res = await fetch("/api/settings/telegram/test", { method: "POST" });
if (res.ok) {
setMsg(t("settings.testSent"));
setMsgType("success");
} else {
const json = await res.json();
setMsg(json.error || t("common.error"));
setMsgType("error");
}
setTesting(false);
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
@@ -182,25 +165,16 @@ export function TelegramCard({
</p>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={testing || !settings?.hasBotToken}
onClick={handleTest}
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t("settings.testMessage")}
</Button>
<Button type="submit" disabled={saving}>
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/telegram/test"
disabled={!settings?.hasBotToken}
/>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("common.save")}
</Button>
@@ -83,6 +83,7 @@ export function TestConnectionButton({
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleClick}
disabled={disabled || testing}
>
+3 -1
View File
@@ -163,7 +163,7 @@ export function WebPushCard() {
description={t("settings.webPushDescription")}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-4">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -182,6 +182,7 @@ export function WebPushCard() {
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
disabled={actionLoading}
>
@@ -194,6 +195,7 @@ export function WebPushCard() {
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
disabled={actionLoading}
>