mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The sweep that follows from the primitives. Nothing here is new behaviour; every change removes a second way of saying something the surface already said one way somewhere else. Headers. The two collapsible security cards stop rebuilding `SettingsCardHeader` by hand to carry a chevron — the chevron rides inside the title button, and the description moves out of the collapsed region, so the first paint is no longer a bare title. The import card shell, the export and import section headings, and the four free-standing group headers stop inventing their own type scale: a heading above a GROUP of cards is `SectionHeading` (`text-base`), a card title is `SettingsCardHeader` (`text-lg`), and the step between them is what says which is which. The targets card gets a title instead of a naked icon. Actions. Every card-level row is `SettingsCardActions`. The primary action comes out of the header's status slot in six cards (password, web push, pillar order, coach memory, and the two danger-zone triggers) and out of the middle of the card in three more; the reset that hung in the dashboard-layout header joins save as a secondary in the same row. Result and status lines move ABOVE the row — sources' cross-link, the invite form's two status lines — because nothing follows a card's actions. Revoking a share link is `variant="destructive"` rather than an `outline` hand-tinted red in two different ways. The import row had four variants for four buttons; it has two. Export downloads are the card's primary, not an outline link. Integrations. One description sentence with the setup-guide link inline: the second paragraph was byte-identical across six of eight cards, which is the tell that it said nothing about the provider it sat under. The `<hr>` under the header — which existed only in this folder — is gone. So is the hint that pointed at a guide 40 px above it, and the second format for "not configured". Connect is one button everywhere: primary, `sm`, in the action row, instead of primary-and-small in three cards and outline-and-large in four. Plus the standards leftovers the audit listed: two `pl-5` list gutters, one `scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four hand-rolled callout shells that `SettingsInfoTile` already paints, and the two inline buttons that never had the mobile tap floor at all.
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
|
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { PasswordInput } from "@/components/ui/password-input";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
|
import {
|
|
optimisticallySetChannelEnabled,
|
|
persistChannelEnabled,
|
|
rollbackChannelEnabled,
|
|
} from "@/components/settings/notification-channel-toggle";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
|
|
|
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 [saving, setSaving] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
|
|
|
const { data: settings } = useQuery({
|
|
queryKey: queryKeys.telegramSettings(),
|
|
queryFn: async () => {
|
|
return apiGet<TelegramSettings>("/api/settings/telegram");
|
|
},
|
|
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.chatId ?? "") : null;
|
|
const [seededKey, setSeededKey] = useState<string | null>(null);
|
|
if (settingsKey && settingsKey !== seededKey) {
|
|
setSeededKey(settingsKey);
|
|
if (settings!.chatId) setChatId(settings!.chatId);
|
|
}
|
|
|
|
const toggleEnabled = useMutation<
|
|
void,
|
|
Error,
|
|
boolean,
|
|
TelegramSettings | undefined
|
|
>({
|
|
mutationFn: (enabled) =>
|
|
persistChannelEnabled(
|
|
"/api/settings/telegram",
|
|
enabled,
|
|
t("settings.savingError"),
|
|
),
|
|
onMutate: async (enabled) => {
|
|
const queryKey = queryKeys.telegramSettings();
|
|
await queryClient.cancelQueries({ queryKey });
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
return optimisticallySetChannelEnabled<TelegramSettings>(
|
|
queryClient,
|
|
queryKey,
|
|
enabled,
|
|
);
|
|
},
|
|
onSuccess: () => {
|
|
setMsg(t("settings.saved"));
|
|
setMsgType("success");
|
|
},
|
|
onError: (err, _enabled, previous) => {
|
|
rollbackChannelEnabled(
|
|
queryClient,
|
|
queryKeys.telegramSettings(),
|
|
previous,
|
|
);
|
|
setMsg(err.message);
|
|
setMsgType("error");
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() });
|
|
},
|
|
});
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
|
|
const body: Record<string, unknown> = {
|
|
enabled: settings?.enabled ?? false,
|
|
};
|
|
if (botToken.trim()) body.botToken = botToken.trim();
|
|
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
|
|
|
|
const res = await apiFetchRaw("/api/settings/telegram", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setMsg(t("settings.saved"));
|
|
setMsgType("success");
|
|
setBotToken("");
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
|
|
} else {
|
|
const json = await res.json();
|
|
setMsg(json.error || t("settings.savingError"));
|
|
setMsgType("error");
|
|
}
|
|
setSaving(false);
|
|
}
|
|
|
|
return (
|
|
<SettingsCard>
|
|
<SettingsCardHeader
|
|
icon={MessageCircle}
|
|
title={t("settings.telegram")}
|
|
description={t("settings.telegramDescription")}
|
|
/>
|
|
|
|
<div className="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}
|
|
autoComplete="off"
|
|
inputMode="text"
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
enterKeyHint="next"
|
|
/>
|
|
</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}
|
|
autoComplete="off"
|
|
inputMode="text"
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
enterKeyHint="done"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="tg-enabled"
|
|
checked={settings?.enabled ?? false}
|
|
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
|
disabled={saving || toggleEnabled.isPending}
|
|
/>
|
|
<Label htmlFor="tg-enabled" className="cursor-pointer">
|
|
{t("settings.enableNotifications")}
|
|
</Label>
|
|
</div>
|
|
|
|
<SettingsInfoTile icon={Info} tone="neutral">
|
|
<ol className="list-decimal space-y-1 pl-4">
|
|
<li>{t("settings.telegramStep1")}</li>
|
|
<li>{t("settings.telegramStep2")}</li>
|
|
<li>{t("settings.telegramStep3")}</li>
|
|
</ol>
|
|
</SettingsInfoTile>
|
|
|
|
{msg && (
|
|
<p
|
|
role="alert"
|
|
className={`text-sm ${
|
|
msgType === "success" ? "text-success" : "text-destructive"
|
|
}`}
|
|
>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
|
|
<SettingsCardActions>
|
|
<TestConnectionButton
|
|
endpoint="/api/settings/telegram/test"
|
|
disabled={!settings?.hasBotToken}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
disabled={saving || toggleEnabled.isPending}
|
|
className="min-h-11"
|
|
>
|
|
{saving ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Save className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</SettingsCardActions>
|
|
</form>
|
|
</div>
|
|
</SettingsCard>
|
|
);
|
|
}
|