mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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.
242 lines
7.6 KiB
TypeScript
242 lines
7.6 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 { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
|
|
|
/** 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 apiFetchRaw("/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;
|
|
|
|
// Defer to the app-wide registration (`<ServiceWorkerRegistrar>` in
|
|
// the root providers) rather than re-registering here — same scope,
|
|
// so a second `register("/sw.js")` would only return the existing
|
|
// worker. `ready` resolves with the active registration once it is
|
|
// controlling the page.
|
|
const registration = await navigator.serviceWorker.ready;
|
|
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
|
|
});
|
|
|
|
const subJson = subscription.toJSON();
|
|
|
|
const res = await apiFetchRaw("/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 apiFetchRaw("/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);
|
|
}
|
|
}
|
|
|
|
// The subscribe / unsubscribe control is this card's primary action, so it
|
|
// sits where every other card's does: the bottom action row. It used to ride
|
|
// the header status slot as an `outline`, which made the one card in the
|
|
// channel group that carries an enable button look like the one card that
|
|
// carries a status badge.
|
|
const cardAction = loading ? (
|
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
) : !isSupported || isDenied ? null : (
|
|
<SettingsCardActions>
|
|
{isSubscribed && (
|
|
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
|
|
)}
|
|
{isSubscribed ? (
|
|
<Button
|
|
size="sm"
|
|
className="min-h-11"
|
|
onClick={handleUnsubscribe}
|
|
disabled={actionLoading}
|
|
>
|
|
{actionLoading && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("settings.webPushUnsubscribe")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
className="min-h-11"
|
|
onClick={handleSubscribe}
|
|
disabled={actionLoading}
|
|
>
|
|
{actionLoading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<BellRing className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("settings.webPushSubscribe")}
|
|
</Button>
|
|
)}
|
|
</SettingsCardActions>
|
|
);
|
|
|
|
return (
|
|
<SettingsCard>
|
|
<SettingsCardHeader
|
|
icon={BellRing}
|
|
title={t("settings.webPush")}
|
|
description={t("settings.webPushDescription")}
|
|
/>
|
|
|
|
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
|
|
<div className="space-y-4">
|
|
{!loading && !isSupported ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("settings.webPushNotSupported")}
|
|
</p>
|
|
) : !loading && isDenied ? (
|
|
<p className="text-destructive text-sm">
|
|
{t("settings.webPushDenied")}
|
|
</p>
|
|
) : null}
|
|
|
|
{msg && (
|
|
<p
|
|
role="alert"
|
|
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
|
>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{cardAction}
|
|
</SettingsCard>
|
|
);
|
|
}
|