mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
b64f48bce3
commit
3ce0b69802
17 files changed
+248
-279
No files matched your search
+3
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user