Files
HealthLog/src/components/settings/web-push-card.tsx
T
Marc-André Bombeck 3ce0b69802 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)
2026-06-05 03:45:04 +02:00

228 lines
7.0 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const buffer = new ArrayBuffer(raw.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < raw.length; ++i) {
view[i] = raw.charCodeAt(i);
}
return buffer;
}
export function WebPushCard() {
const { t } = useTranslations();
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSupported, setIsSupported] = useState(true);
const [isDenied, setIsDenied] = useState(false);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
useEffect(() => {
// Async I/O (browser API + service worker registration). The lint rule
// allows setState inside async callbacks — only synchronous setState in
// the effect body is rejected.
async function checkSubscription() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setIsSupported(false);
setLoading(false);
return;
}
if (Notification.permission === "denied") {
setIsDenied(true);
setLoading(false);
return;
}
try {
const registration =
await navigator.serviceWorker.getRegistration("/sw.js");
if (registration) {
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
}
} catch {
// Ignore errors during check
}
setLoading(false);
}
void checkSubscription();
}, []);
async function handleSubscribe() {
setActionLoading(true);
setMsg(null);
setMsgType(null);
try {
const vapidRes = await fetch("/api/notifications/vapid");
if (!vapidRes.ok) {
setMsg(t("settings.webPushNotConfigured"));
setMsgType("error");
setActionLoading(false);
return;
}
const vapidData = await vapidRes.json();
const vapidPublicKey = vapidData.data.publicKey;
const registration = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
});
const subJson = subscription.toJSON();
const res = await fetch("/api/notifications/web-push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: {
p256dh: subJson.keys?.p256dh ?? "",
auth: subJson.keys?.auth ?? "",
},
}),
});
if (res.ok) {
setIsSubscribed(true);
setMsg(t("settings.webPushSubscribed"));
setMsgType("success");
} else {
setMsg(t("settings.webPushSubscribeFailed"));
setMsgType("error");
}
} catch {
if (Notification.permission === "denied") {
setIsDenied(true);
setMsg(t("settings.webPushDenied"));
} else {
setMsg(t("settings.webPushSubscribeFailed"));
}
setMsgType("error");
} finally {
setActionLoading(false);
}
}
async function handleUnsubscribe() {
setActionLoading(true);
setMsg(null);
setMsgType(null);
try {
const registration =
await navigator.serviceWorker.getRegistration("/sw.js");
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await fetch("/api/notifications/web-push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
}
setIsSubscribed(false);
setMsg(t("settings.webPushUnsubscribed"));
setMsgType("success");
} catch {
setMsg(t("settings.webPushSubscribeFailed"));
setMsgType("error");
} finally {
setActionLoading(false);
}
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
icon={BellRing}
title={t("settings.webPush")}
description={t("settings.webPushDescription")}
/>
<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" />
</div>
) : !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
</p>
) : isDenied ? (
<p className="text-destructive text-sm">
{t("settings.webPushDenied")}
</p>
) : (
<div className="flex flex-wrap items-start gap-2">
{isSubscribed ? (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<BellRing className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
)}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
</div>
);
}