mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations, Notifications, Dashboard, API, Advanced, and About with real per-section components. Every /settings/<slug> URL now renders working content; the placeholder only fires for unknown slugs. A2-content extraction - Account: profile + passkeys + password change (account-section.tsx) - AI: insights + per-user provider override (ai-section.tsx) - Integrations: Withings + moodLog (integrations-section.tsx) - Notifications: Telegram + ntfy + Web Push (split into telegram-card, ntfy-card, web-push-card to keep each file under ~600 LOC) - Dashboard: layout + thresholds wrappers (dashboard-section.tsx) - API: endpoints + tokens (api-section.tsx) - Advanced: export + danger zone (advanced-section.tsx) - About (greenfield): version, license, build SHA, repo/changelog/docs links + "Check for updates" against GitHub releases A2-about - Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0 badge, short build SHA, build timestamp, repo/changelog/docs links. - "Check for updates" button compares against api.github.com latest release tag with up-to-date / newer-available / failure feedback. A8-UI test buttons - Shared TestConnectionButton primitive that POSTs to a test endpoint, surfaces ok-with-latency or translated meta.errorCode failures. - Wired into Withings + moodLog (Integrations) and Web Push (Notifications). Glitchtip + Umami remain admin-only. i18n - New settings.about.* and settings.testConnection.* key groups in en.json and de.json. Cleanup - Delete src/app/settings/page.legacy.tsx (3230 LOC monolith). - Convert sync-from-server patterns to the React-recommended prev-payload-id render-time pattern so the strict react-hooks/set-state-in-effect rule passes without a file-level disable. Tests - New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke tests (one per section) — total suite 514 (was 505).
218 lines
6.9 KiB
TypeScript
218 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Loader2, MessageCircle, Save, Send } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { PasswordInput } from "@/components/settings/password-input";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
interface TelegramSettings {
|
|
enabled: boolean;
|
|
hasBotToken: boolean;
|
|
chatId: string | null;
|
|
}
|
|
|
|
export function TelegramCard({
|
|
isAuthenticated,
|
|
}: {
|
|
isAuthenticated: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [botToken, setBotToken] = useState("");
|
|
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);
|
|
|
|
const { data: settings } = useQuery({
|
|
queryKey: ["telegram", "settings"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/settings/telegram");
|
|
if (!res.ok) throw new Error("Failed");
|
|
const json = await res.json();
|
|
return json.data as TelegramSettings;
|
|
},
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
// Sync from server using the React-recommended "previous-payload-id"
|
|
// pattern instead of setState-in-effect. This avoids the strict
|
|
// `react-hooks/set-state-in-effect` lint failure that the legacy file
|
|
// disabled at module scope.
|
|
const settingsKey = settings
|
|
? `${settings.enabled}|${settings.chatId ?? ""}`
|
|
: null;
|
|
const [seededKey, setSeededKey] = useState<string | null>(null);
|
|
if (settingsKey && settingsKey !== seededKey) {
|
|
setSeededKey(settingsKey);
|
|
setEnabled(settings!.enabled);
|
|
if (settings!.chatId) setChatId(settings!.chatId);
|
|
}
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
|
|
const body: Record<string, unknown> = { enabled };
|
|
if (botToken.trim()) body.botToken = botToken.trim();
|
|
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
|
|
|
|
const res = await fetch("/api/settings/telegram", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setMsg(t("settings.telegramSaved"));
|
|
setMsgType("success");
|
|
setBotToken("");
|
|
queryClient.invalidateQueries({ queryKey: ["telegram"] });
|
|
} else {
|
|
const json = await res.json();
|
|
setMsg(json.error || t("settings.savingError"));
|
|
setMsgType("error");
|
|
}
|
|
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">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<MessageCircle className="text-primary h-5 w-5" />
|
|
<h2 className="text-lg font-semibold">{t("settings.telegram")}</h2>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{settings?.hasBotToken && settings?.chatId && (
|
|
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
|
{t("settings.configured")}
|
|
</Badge>
|
|
)}
|
|
{settings?.enabled && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{t("common.enabled")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
{t("settings.telegramDescription")}
|
|
</p>
|
|
|
|
<div className="mt-4 space-y-4">
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tg-token">{t("settings.botToken")}</Label>
|
|
<PasswordInput
|
|
id="tg-token"
|
|
value={botToken}
|
|
onChange={(e) => setBotToken(e.target.value)}
|
|
placeholder={
|
|
settings?.hasBotToken
|
|
? t("settings.withingsCredentialsSavedPlaceholder")
|
|
: "123456:ABC-DEF..."
|
|
}
|
|
maxLength={100}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tg-chatid">{t("settings.chatId")}</Label>
|
|
<Input
|
|
id="tg-chatid"
|
|
value={chatId}
|
|
onChange={(e) => setChatId(e.target.value)}
|
|
placeholder="123456789"
|
|
maxLength={50}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="tg-enabled"
|
|
checked={enabled}
|
|
onCheckedChange={setEnabled}
|
|
/>
|
|
<Label htmlFor="tg-enabled" className="cursor-pointer">
|
|
{t("settings.enableNotifications")}
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-xs">
|
|
<p>{t("settings.telegramStep1")}</p>
|
|
<p>{t("settings.telegramStep2")}</p>
|
|
<p>{t("settings.telegramStep3")}</p>
|
|
</div>
|
|
|
|
{msg && (
|
|
<p
|
|
role="alert"
|
|
className={`text-sm ${
|
|
msgType === "success"
|
|
? "text-dracula-green"
|
|
: "text-destructive"
|
|
}`}
|
|
>
|
|
{msg}
|
|
</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" />
|
|
) : (
|
|
<Send className="mr-2 h-4 w-4" />
|
|
)}
|
|
{t("settings.testMessage")}
|
|
</Button>
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="mr-2 h-4 w-4" />
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|