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

+3 -7
View File
@@ -3327,12 +3327,9 @@
"backedUp": "gesichert",
"telegram": "Telegram",
"telegramDescription": "Medikamenten-Erinnerungen per Bot.",
"telegramSaved": "Telegram-Einstellungen gespeichert",
"botToken": "Bot-Token",
"chatId": "Chat-ID",
"enableNotifications": "Benachrichtigungen aktivieren",
"testMessage": "Test-Nachricht",
"testSent": "Test-Nachricht gesendet!",
"telegramStep1": "1. Erstelle einen Bot über @BotFather in Telegram und kopiere den Token.",
"telegramStep2": "2. Sende /start an deinen Bot, um den Chat zu aktivieren.",
"telegramStep3": "3. Finde deine Chat-ID über @userinfobot oder die Bot-API.",
@@ -3663,9 +3660,9 @@
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Webhook-Secret",
"moodLogWebhookSecretHelp": "Dieses Secret in moodLog als Webhook-Secret eintragen",
"moodLogSync": "Sync starten",
"moodLogFullSync": "Voll-Sync",
"moodLogFullSyncConfirm": "Vollständig synchronisieren",
"moodLogSync": "Jetzt synchronisieren",
"moodLogFullSync": "Alle Daten synchronisieren",
"moodLogFullSyncConfirm": "Synchronisieren",
"moodLogFullSyncTitle": "Vollsynchronisierung?",
"moodLogFullSyncDescription": "Alle verfügbaren Stimmungsdaten werden vollständig synchronisiert.",
"moodLogDisconnect": "Trennen",
@@ -3674,7 +3671,6 @@
"moodLogEntries": "Einträge",
"moodLogSyncResult": "{count} Einträge synchronisiert",
"moodLogSyncFailed": "Sync fehlgeschlagen",
"moodLogSaved": "moodLog-Verbindung gespeichert",
"moodLogDisconnected": "moodLog getrennt",
"about": {
"version": "App-Version",
+3 -7
View File
@@ -3327,12 +3327,9 @@
"backedUp": "backed up",
"telegram": "Telegram",
"telegramDescription": "Medication reminders via bot.",
"telegramSaved": "Telegram settings saved",
"botToken": "Bot Token",
"chatId": "Chat ID",
"enableNotifications": "Enable notifications",
"testMessage": "Test message",
"testSent": "Test message sent!",
"telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.",
"telegramStep2": "2. Send /start to your bot to activate the chat.",
"telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.",
@@ -3663,9 +3660,9 @@
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Webhook Secret",
"moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret",
"moodLogSync": "Start sync",
"moodLogFullSync": "Full sync",
"moodLogFullSyncConfirm": "Synchronize fully",
"moodLogSync": "Sync now",
"moodLogFullSync": "Sync all data",
"moodLogFullSyncConfirm": "Synchronize",
"moodLogFullSyncTitle": "Full sync?",
"moodLogFullSyncDescription": "All available mood data will be fully synchronized.",
"moodLogDisconnect": "Disconnect",
@@ -3674,7 +3671,6 @@
"moodLogEntries": "Entries",
"moodLogSyncResult": "{count} entries synchronized",
"moodLogSyncFailed": "Sync failed",
"moodLogSaved": "moodLog connection saved",
"moodLogDisconnected": "moodLog disconnected",
"about": {
"version": "App version",
+3 -7
View File
@@ -3327,12 +3327,9 @@
"backedUp": "con copia de seguridad",
"telegram": "Telegram Notifications",
"telegramDescription": "Get reminders for missed medication intake via Telegram.",
"telegramSaved": "Ajustes de Telegram guardados",
"botToken": "Token del bot",
"chatId": "ID del chat",
"enableNotifications": "Activar notificaciones",
"testMessage": "Mensaje de prueba",
"testSent": "¡Mensaje de prueba enviado!",
"telegramStep1": "1. Crea un bot con @BotFather en Telegram y copia el token.",
"telegramStep2": "2. Envía /start a tu bot para activar el chat.",
"telegramStep3": "3. Obtén tu ID del chat con @userinfobot o la API del bot.",
@@ -3663,9 +3660,9 @@
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Secreto del webhook",
"moodLogWebhookSecretHelp": "Introduce este secreto en moodLog como secreto del webhook",
"moodLogSync": "Iniciar sincronización",
"moodLogFullSync": "Sincronización completa",
"moodLogFullSyncConfirm": "Sincronizar por completo",
"moodLogSync": "Sincronizar ahora",
"moodLogFullSync": "Sincronizar todos los datos",
"moodLogFullSyncConfirm": "Sincronizar",
"moodLogFullSyncTitle": "¿Sincronización completa?",
"moodLogFullSyncDescription": "Se sincronizarán por completo todos los datos de estado de ánimo disponibles.",
"moodLogDisconnect": "Desconectar",
@@ -3674,7 +3671,6 @@
"moodLogEntries": "Entradas",
"moodLogSyncResult": "{count} entradas sincronizadas",
"moodLogSyncFailed": "Falló la sincronización",
"moodLogSaved": "Conexión con moodLog guardada",
"moodLogDisconnected": "moodLog desconectado",
"about": {
"version": "Versión de la app",
+3 -7
View File
@@ -3327,12 +3327,9 @@
"backedUp": "sauvegardée",
"telegram": "Telegram Notifications",
"telegramDescription": "Get reminders for missed medication intake via Telegram.",
"telegramSaved": "Réglages Telegram enregistrés",
"botToken": "Jeton du bot",
"chatId": "ID du chat",
"enableNotifications": "Activer les notifications",
"testMessage": "Message de test",
"testSent": "Message de test envoyé !",
"telegramStep1": "1. Créez un bot via @BotFather dans Telegram et copiez le jeton.",
"telegramStep2": "2. Envoyez /start à votre bot pour activer le chat.",
"telegramStep3": "3. Trouvez votre ID de chat via @userinfobot ou l’API du bot.",
@@ -3663,9 +3660,9 @@
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Secret du webhook",
"moodLogWebhookSecretHelp": "Saisissez ce secret dans moodLog comme secret de webhook",
"moodLogSync": "Démarrer la synchronisation",
"moodLogFullSync": "Synchronisation complète",
"moodLogFullSyncConfirm": "Synchroniser entièrement",
"moodLogSync": "Synchroniser maintenant",
"moodLogFullSync": "Synchroniser toutes les données",
"moodLogFullSyncConfirm": "Synchroniser",
"moodLogFullSyncTitle": "Synchronisation complète ?",
"moodLogFullSyncDescription": "Toutes les données d’humeur disponibles seront entièrement synchronisées.",
"moodLogDisconnect": "Déconnecter",
@@ -3674,7 +3671,6 @@
"moodLogEntries": "Entrées",
"moodLogSyncResult": "{count} entrées synchronisées",
"moodLogSyncFailed": "Échec de la synchronisation",
"moodLogSaved": "Connexion moodLog enregistrée",
"moodLogDisconnected": "moodLog déconnecté",
"about": {
"version": "Version de l’app",
+3 -7
View File
@@ -3327,12 +3327,9 @@
"backedUp": "con backup",
"telegram": "Telegram Notifications",
"telegramDescription": "Get reminders for missed medication intake via Telegram.",
"telegramSaved": "Impostazioni Telegram salvate",
"botToken": "Token del bot",
"chatId": "ID chat",
"enableNotifications": "Attiva le notifiche",
"testMessage": "Messaggio di prova",
"testSent": "Messaggio di prova inviato!",
"telegramStep1": "1. Crea un bot tramite @BotFather su Telegram e copia il token.",
"telegramStep2": "2. Invia /start al tuo bot per attivare la chat.",
"telegramStep3": "3. Trova il tuo ID chat tramite @userinfobot o l’API del bot.",
@@ -3663,9 +3660,9 @@
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Secret del webhook",
"moodLogWebhookSecretHelp": "Inserisci questo secret in moodLog come secret del webhook",
"moodLogSync": "Avvia sincronizzazione",
"moodLogFullSync": "Sincronizzazione completa",
"moodLogFullSyncConfirm": "Sincronizza completamente",
"moodLogSync": "Sincronizza ora",
"moodLogFullSync": "Sincronizza tutti i dati",
"moodLogFullSyncConfirm": "Sincronizza",
"moodLogFullSyncTitle": "Sincronizzazione completa?",
"moodLogFullSyncDescription": "Tutti i dati sull’umore disponibili verranno sincronizzati completamente.",
"moodLogDisconnect": "Disconnetti",
@@ -3674,7 +3671,6 @@
"moodLogEntries": "Voci",
"moodLogSyncResult": "{count} voci sincronizzate",
"moodLogSyncFailed": "Sincronizzazione non riuscita",
"moodLogSaved": "Connessione moodLog salvata",
"moodLogDisconnected": "moodLog disconnesso",
"about": {
"version": "Versione dell’app",
+3 -7
View File
@@ -3327,12 +3327,9 @@
"backedUp": "z kopią zapasową",
"telegram": "Telegram Notifications",
"telegramDescription": "Get reminders for missed medication intake via Telegram.",
"telegramSaved": "Ustawienia Telegrama zapisane",
"botToken": "Token bota",
"chatId": "ID czatu",
"enableNotifications": "Włącz powiadomienia",
"testMessage": "Wiadomość testowa",
"testSent": "Wiadomość testowa wysłana!",
"telegramStep1": "1. Utwórz bota przez @BotFather w Telegramie i skopiuj token.",
"telegramStep2": "2. Wyślij /start do swojego bota, aby aktywować czat.",
"telegramStep3": "3. Znajdź swoje ID czatu przez @userinfobot lub API bota.",
@@ -3663,9 +3660,9 @@
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Sekret webhooka",
"moodLogWebhookSecretHelp": "Wprowadź ten sekret w moodLog jako sekret webhooka",
"moodLogSync": "Rozpocznij synchronizację",
"moodLogFullSync": "Pełna synchronizacja",
"moodLogFullSyncConfirm": "Synchronizuj w pełni",
"moodLogSync": "Synchronizuj teraz",
"moodLogFullSync": "Synchronizuj wszystkie dane",
"moodLogFullSyncConfirm": "Synchronizuj",
"moodLogFullSyncTitle": "Pełna synchronizacja?",
"moodLogFullSyncDescription": "Wszystkie dostępne dane o nastroju zostaną w pełni zsynchronizowane.",
"moodLogDisconnect": "Rozłącz",
@@ -3674,7 +3671,6 @@
"moodLogEntries": "Wpisy",
"moodLogSyncResult": "{count} wpisów zsynchronizowano",
"moodLogSyncFailed": "Synchronizacja nie powiodła się",
"moodLogSaved": "Połączenie moodLog zapisane",
"moodLogDisconnected": "moodLog rozłączony",
"about": {
"version": "Wersja aplikacji",
+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}
>