chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, BellRing, ChevronDown, Clock3, Languages, LayoutDashboard, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
MapPinned,
|
||||
Palette,
|
||||
Settings,
|
||||
Smartphone,
|
||||
Thermometer,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -14,13 +27,26 @@ import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
||||
import {
|
||||
decodeBase64UrlKey,
|
||||
deletePushSubscription,
|
||||
fetchVapidPublicKey,
|
||||
savePushSubscription,
|
||||
sendTestPushNotification,
|
||||
} from "@/lib/notification-api";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||
type NotificationSupportStatus =
|
||||
| "checking"
|
||||
| "unconfigured"
|
||||
| "unsupported"
|
||||
| "needs-install"
|
||||
| "default"
|
||||
| "denied"
|
||||
| "granted";
|
||||
|
||||
function isStandaloneApp() {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -38,12 +64,23 @@ function isAppleMobilePlatform() {
|
||||
|
||||
function getNotificationSupportStatus(): NotificationSupportStatus {
|
||||
if (typeof window === "undefined") return "checking";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window))
|
||||
return "unsupported";
|
||||
if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install";
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
function SettingsRow({ icon: Icon, title, description, children }: { icon: typeof Settings; title: string; description: string; children: React.ReactNode }) {
|
||||
function SettingsRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Settings;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -64,24 +101,36 @@ function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: b
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
|
||||
checked
|
||||
? "border-accent/55 bg-accent"
|
||||
: "border-border/70 bg-surface-muted"
|
||||
checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
|
||||
} ${disabled ? "opacity-55" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`} />
|
||||
<span
|
||||
className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSwitchLabel({ icon: Icon, title, description, checked, disabled, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
|
||||
function NotificationSwitchLabel({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
|
||||
checked
|
||||
? "border-accent/45 bg-accent/10"
|
||||
: "border-border/60 bg-surface"
|
||||
checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
|
||||
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
|
||||
>
|
||||
<input
|
||||
@@ -103,7 +152,10 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa
|
||||
);
|
||||
}
|
||||
|
||||
const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"> = {
|
||||
const windSpeedUnitKeys: Record<
|
||||
WindSpeedUnit,
|
||||
"settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"
|
||||
> = {
|
||||
ms: "settings.units.wind.ms",
|
||||
kmh: "settings.units.wind.kmh",
|
||||
mph: "settings.units.wind.mph",
|
||||
@@ -145,25 +197,37 @@ export function SettingsPage() {
|
||||
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
|
||||
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
|
||||
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
||||
?? stations?.[0];
|
||||
const selectedStation =
|
||||
stations?.find((station) => station.id === selectedStationId) ??
|
||||
stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
|
||||
stations?.[0];
|
||||
const activeLocation = selectedLocation;
|
||||
const selectedRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude)
|
||||
? activeLocation?.latitude
|
||||
: (stationPosition?.latitude ?? null);
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
|
||||
? activeLocation?.longitude
|
||||
: (stationPosition?.longitude ?? null);
|
||||
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
||||
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
|
||||
const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null;
|
||||
const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null;
|
||||
const effectiveProvince = selectedRegion === "PL" ? provinceMode === "manual" ? manualProvince : selectedProvince : null;
|
||||
const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
|
||||
const selectedProvince =
|
||||
selectedRegion === "PL"
|
||||
? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID)
|
||||
: null;
|
||||
const effectiveProvince =
|
||||
selectedRegion === "PL" ? (provinceMode === "manual" ? manualProvince : selectedProvince) : null;
|
||||
const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince);
|
||||
const effectiveProvinceLabel = selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
const effectiveProvinceLabel =
|
||||
selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince
|
||||
? formatProvinceName(effectiveProvince, language)
|
||||
: t("settings.notifications.noProvince");
|
||||
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,7 +275,24 @@ export function SettingsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canUseOfficialWarnings, effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, notificationTimezone, selectedRegion, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
||||
}, [
|
||||
canUseOfficialWarnings,
|
||||
effectiveProvince,
|
||||
language,
|
||||
morningBriefEnabled,
|
||||
notificationCountyTeryt,
|
||||
notificationLatitude,
|
||||
notificationLocationName,
|
||||
notificationLongitude,
|
||||
notificationStatus,
|
||||
notificationsEnabled,
|
||||
notificationTimezone,
|
||||
selectedRegion,
|
||||
temperatureUnit,
|
||||
tomorrowBriefEnabled,
|
||||
vapidPublicKey,
|
||||
windSpeedUnit,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provinceDropdownOpen) return;
|
||||
@@ -243,11 +324,18 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [notificationStatus, t]);
|
||||
|
||||
const canUsePush = Boolean(vapidPublicKey && (selectedRegion === "GLOBAL" || effectiveProvince) && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
|
||||
const canUsePush = Boolean(
|
||||
vapidPublicKey &&
|
||||
(selectedRegion === "GLOBAL" || effectiveProvince) &&
|
||||
notificationStatus !== "unsupported" &&
|
||||
notificationStatus !== "needs-install" &&
|
||||
notificationStatus !== "denied" &&
|
||||
notificationStatus !== "unconfigured",
|
||||
);
|
||||
|
||||
async function getServiceWorkerRegistration() {
|
||||
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
||||
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js");
|
||||
return (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register("/sw.js"));
|
||||
}
|
||||
|
||||
async function enableWarningNotifications() {
|
||||
@@ -270,10 +358,12 @@ export function SettingsPage() {
|
||||
}
|
||||
const registration = await getServiceWorkerRegistration();
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
const subscription =
|
||||
existingSubscription ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
}));
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings,
|
||||
@@ -387,20 +477,35 @@ export function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<p className="section-kicker flex items-center gap-2"><Settings className="size-4" />{t("settings.section")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<Settings className="size-4" />
|
||||
{t("settings.section")}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
|
||||
</section>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<SettingsRow icon={Languages} title={t("settings.language.title")} description={t("settings.language.description")}>
|
||||
<SettingsRow
|
||||
icon={Languages}
|
||||
title={t("settings.language.title")}
|
||||
description={t("settings.language.description")}
|
||||
>
|
||||
<LanguageToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
||||
<ThemeToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Thermometer} title={t("settings.units.temperature.title")} description={t("settings.units.temperature.description")}>
|
||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}>
|
||||
<SettingsRow
|
||||
icon={Thermometer}
|
||||
title={t("settings.units.temperature.title")}
|
||||
description={t("settings.units.temperature.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.temperature.label")}
|
||||
>
|
||||
{TEMPERATURE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
@@ -419,8 +524,16 @@ export function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
|
||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
|
||||
<SettingsRow
|
||||
icon={Wind}
|
||||
title={t("settings.units.wind.title")}
|
||||
description={t("settings.units.wind.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.wind.label")}
|
||||
>
|
||||
{WIND_SPEED_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
@@ -509,7 +622,9 @@ export function SettingsPage() {
|
||||
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">
|
||||
{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,7 +640,11 @@ export function SettingsPage() {
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{t("settings.notifications.regionSelected")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedProvince ? formatProvinceName(selectedProvince, language) : t("settings.notifications.noProvince")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{selectedProvince
|
||||
? formatProvinceName(selectedProvince, language)
|
||||
: t("settings.notifications.noProvince")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
@@ -550,7 +669,8 @@ export function SettingsPage() {
|
||||
aria-expanded={provinceDropdownOpen}
|
||||
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false);
|
||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
|
||||
setProvinceDropdownOpen(false);
|
||||
}}
|
||||
onClick={() => setProvinceDropdownOpen((current) => !current)}
|
||||
className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60"
|
||||
@@ -581,7 +701,9 @@ export function SettingsPage() {
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{formatProvinceName(province, language)}</span>
|
||||
{manualProvinceValue === province && <span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />}
|
||||
{manualProvinceValue === province && (
|
||||
<span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -621,9 +743,15 @@ export function SettingsPage() {
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Bell className="size-3.5 text-accent" aria-hidden="true" />
|
||||
{selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : t("settings.notifications.enable")}
|
||||
{selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.enableDevice")
|
||||
: t("settings.notifications.enable")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.enableGlobalDescription")
|
||||
: t("settings.notifications.enableDescription")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedRegion === "GLOBAL" ? t("settings.notifications.enableGlobalDescription") : t("settings.notifications.enableDescription")}</span>
|
||||
<span className="mt-2 block text-xs font-semibold text-accent">
|
||||
{isSavingSubscription
|
||||
? t("settings.notifications.saving")
|
||||
@@ -632,7 +760,10 @@ export function SettingsPage() {
|
||||
: t("settings.notifications.disabledStatus")}
|
||||
</span>
|
||||
</span>
|
||||
<SwitchIndicator checked={notificationsEnabled} disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} />
|
||||
<SwitchIndicator
|
||||
checked={notificationsEnabled}
|
||||
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
|
||||
/>
|
||||
</button>
|
||||
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||
|
||||
@@ -655,9 +786,16 @@ export function SettingsPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
|
||||
onClick={sendTestNotification}
|
||||
>
|
||||
<BellRing className="size-4" />
|
||||
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")}
|
||||
{isSendingTestNotification
|
||||
? t("settings.notifications.sendingTest")
|
||||
: t("settings.notifications.sendTest")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user