Add settings page for alert preferences
This commit is contained in:
@@ -2,15 +2,13 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CloudSun, Droplets, TriangleAlert } from "lucide-react";
|
||||
import { CloudSun, Droplets, Settings, TriangleAlert } from "lucide-react";
|
||||
import { NAV_ITEMS } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InstallPWAButton } from "@/components/ui/install-pwa-button";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { LanguageToggle } from "@/components/ui/language-toggle";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
const icons = [CloudSun, TriangleAlert, Droplets];
|
||||
const icons = [CloudSun, TriangleAlert, Droplets, Settings];
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
@@ -34,8 +32,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<InstallPWAButton />
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -45,9 +41,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const Icon = icons[index];
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-card px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
|
||||
<Link key={item.href} href={item.href} className={cn("flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
|
||||
<Icon className="size-4" />
|
||||
{t(item.labelKey)}
|
||||
<span className="max-w-full truncate">{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
218
components/settings/settings-page.tsx
Normal file
218
components/settings/settings-page.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, BellRing, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { LanguageToggle } from "@/components/ui/language-toggle";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import type { Province } from "@/types/province";
|
||||
|
||||
type NotificationSupportStatus = "checking" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||
|
||||
function isStandaloneApp() {
|
||||
if (typeof window === "undefined") return false;
|
||||
const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean };
|
||||
return window.matchMedia("(display-mode: standalone)").matches || navigatorWithStandalone.standalone === true;
|
||||
}
|
||||
|
||||
function getNotificationSupportStatus(): NotificationSupportStatus {
|
||||
if (typeof window === "undefined") return "checking";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
|
||||
if (!isStandaloneApp()) return "needs-install";
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent">
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<p className="mt-1 max-w-xl text-sm leading-6 text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { language, t } = useI18n();
|
||||
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
|
||||
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
|
||||
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
||||
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
||||
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
|
||||
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
|
||||
|
||||
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
|
||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
|
||||
useEffect(() => {
|
||||
const animationFrame = window.requestAnimationFrame(() => setNotificationStatus(getNotificationSupportStatus()));
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, []);
|
||||
|
||||
const notificationStatusLabel = useMemo(() => {
|
||||
switch (notificationStatus) {
|
||||
case "unsupported":
|
||||
return t("settings.notifications.statusUnsupported");
|
||||
case "needs-install":
|
||||
return t("settings.notifications.statusNeedsInstall");
|
||||
case "denied":
|
||||
return t("settings.notifications.statusDenied");
|
||||
case "granted":
|
||||
return t("settings.notifications.statusGranted");
|
||||
case "default":
|
||||
return t("settings.notifications.statusReady");
|
||||
default:
|
||||
return t("settings.notifications.statusChecking");
|
||||
}
|
||||
}, [notificationStatus, t]);
|
||||
|
||||
const canRequestPermission = notificationStatus === "default";
|
||||
const canEnablePreference = notificationStatus === "granted";
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
if (!("Notification" in window)) {
|
||||
setNotificationStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationStatus(permission);
|
||||
setNotificationsEnabled(permission === "granted");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<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")}>
|
||||
<LanguageToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
||||
<ThemeToggle />
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-control border border-warning/30 bg-warning/10 p-2 text-warning">
|
||||
<BellRing className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">IMGW</p>
|
||||
<h2 className="mt-1 text-lg font-semibold tracking-tight">{t("settings.notifications.title")}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("settings.notifications.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-card border border-border/65 bg-surface-muted/45 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="warning-notification-province-mode"
|
||||
checked={provinceMode === "selected"}
|
||||
onChange={() => setProvinceMode("selected")}
|
||||
className="mt-1 accent-accent"
|
||||
/>
|
||||
<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>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="warning-notification-province-mode"
|
||||
checked={provinceMode === "manual"}
|
||||
onChange={() => {
|
||||
setProvinceMode("manual");
|
||||
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
|
||||
}}
|
||||
className="mt-1 accent-accent"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{t("settings.notifications.regionManual")}</span>
|
||||
<select
|
||||
aria-label={t("settings.notifications.regionManual")}
|
||||
value={manualProvince ?? selectedProvince ?? "mazowieckie"}
|
||||
disabled={provinceMode !== "manual"}
|
||||
onChange={(event) => setManualProvince(event.target.value as Province)}
|
||||
className="surface-control mt-2 w-full rounded-control px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{PROVINCES.map((province) => (
|
||||
<option key={province} value={province}>{formatProvinceName(province, language)}</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border/65 bg-surface-muted/45 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Smartphone className="mt-0.5 size-4 text-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.notifications.deviceTitle")}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{notificationStatusLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-card border border-border/60 bg-surface px-3 py-3">
|
||||
<label className="flex items-center justify-between gap-4 text-sm">
|
||||
<span>
|
||||
<span className="block font-medium">{t("settings.notifications.enable")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationsEnabled}
|
||||
disabled={!canEnablePreference}
|
||||
onChange={(event) => setNotificationsEnabled(event.target.checked)}
|
||||
className="size-5 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" disabled={!canRequestPermission} onClick={requestNotificationPermission}>
|
||||
<Bell className="size-4" />
|
||||
{t("settings.notifications.requestPermission")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user