Files
HealthLog/src/components/settings/webhook-card.tsx
T
Marc-André Bombeck b2e1aa0cde refactor(settings): one header, one action row, one integration card
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.
2026-08-07 19:39:11 +02:00

229 lines
7.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
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 WebhookSettings {
enabled: boolean;
url: string;
headerName: string;
hasHeaderValue: boolean;
}
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
queryFn: async () => {
return apiGet<WebhookSettings>("/api/settings/webhook");
},
enabled: isAuthenticated,
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.url}|${settings.headerName}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setUrl(settings!.url);
setHeaderName(settings!.headerName);
}
const save = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiFetchRaw("/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
headerName: headerName || undefined,
headerValue: headerValue || undefined,
enabled,
}),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setHeaderValue("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
WebhookSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/webhook",
enabled,
t("common.error"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsWebhook();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<WebhookSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.settingsWebhook(),
previous,
);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
icon={Webhook}
title={t("settings.webhook")}
description={t("settings.webhookDescription")}
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-url">{t("settings.webhookUrl")}</Label>
<Input
id="webhook-url"
placeholder="https://gotify.example.com/message?token=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}
</Label>
<Input
id="webhook-header-name"
placeholder="Authorization"
value={headerName}
onChange={(e) => setHeaderName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-value">
{t("settings.webhookHeaderValue")}
</Label>
<PasswordInput
id="webhook-header-value"
placeholder={
settings?.hasHeaderValue
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={headerValue}
onChange={(e) => setHeaderValue(e.target.value)}
/>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
);
}