Add settings page for alert preferences
This commit is contained in:
@@ -115,6 +115,7 @@ components/dashboard dashboard aplikacji
|
|||||||
components/weather/ hero, stacje, metryki i szczegóły
|
components/weather/ hero, stacje, metryki i szczegóły
|
||||||
components/warnings/ alerty meteo i hydro
|
components/warnings/ alerty meteo i hydro
|
||||||
components/hydro/ lista stacji hydrologicznych
|
components/hydro/ lista stacji hydrologicznych
|
||||||
|
components/settings/ ustawienia języka, motywu i preferencji alertów
|
||||||
components/ui/ bazowe komponenty interfejsu
|
components/ui/ bazowe komponenty interfejsu
|
||||||
components/states/ loading, empty i error states
|
components/states/ loading, empty i error states
|
||||||
hooks/ zapytania TanStack Query
|
hooks/ zapytania TanStack Query
|
||||||
@@ -127,6 +128,8 @@ public/ manifest, ikony i service worker
|
|||||||
|
|
||||||
Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości.
|
Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości.
|
||||||
|
|
||||||
|
Widok `/settings` zawiera pierwszy etap konfiguracji powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, preferencję alertów i sprawdzenie zgody Web Push na urządzeniu. Na iOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Realna wysyłka powiadomień wymaga jeszcze backendowego zapisu subskrypcji Web Push, kluczy VAPID oraz cyklicznego sprawdzania endpointu IMGW `warningsmeteo`; obecny UI nie wysyła jeszcze alertów.
|
||||||
|
|
||||||
## Wdrożenie na Vercel
|
## Wdrożenie na Vercel
|
||||||
|
|
||||||
1. Umieść repozytorium w serwisie Git.
|
1. Umieść repozytorium w serwisie Git.
|
||||||
|
|||||||
8
app/settings/page.tsx
Normal file
8
app/settings/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { SettingsPage } from "@/components/settings/settings-page";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ustawienia / Settings" };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <SettingsPage />;
|
||||||
|
}
|
||||||
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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 { NAV_ITEMS } from "@/lib/constants";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { InstallPWAButton } from "@/components/ui/install-pwa-button";
|
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";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
const icons = [CloudSun, TriangleAlert, Droplets];
|
const icons = [CloudSun, TriangleAlert, Droplets, Settings];
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -34,8 +32,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<InstallPWAButton />
|
<InstallPWAButton />
|
||||||
<LanguageToggle />
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -45,9 +41,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
const Icon = icons[index];
|
const Icon = icons[index];
|
||||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
return (
|
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" />
|
<Icon className="size-4" />
|
||||||
{t(item.labelKey)}
|
<span className="max-w-full truncate">{t(item.labelKey)}</span>
|
||||||
</Link>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ export const NAV_ITEMS = [
|
|||||||
{ href: "/", labelKey: "nav.weather" },
|
{ href: "/", labelKey: "nav.weather" },
|
||||||
{ href: "/warnings", labelKey: "nav.warnings" },
|
{ href: "/warnings", labelKey: "nav.warnings" },
|
||||||
{ href: "/hydro", labelKey: "nav.hydro" },
|
{ href: "/hydro", labelKey: "nav.hydro" },
|
||||||
|
{ href: "/settings", labelKey: "nav.settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
52
lib/i18n.tsx
52
lib/i18n.tsx
@@ -10,6 +10,7 @@ const translations = {
|
|||||||
"nav.weather": "Pogoda",
|
"nav.weather": "Pogoda",
|
||||||
"nav.warnings": "Ostrzeżenia",
|
"nav.warnings": "Ostrzeżenia",
|
||||||
"nav.hydro": "Hydro",
|
"nav.hydro": "Hydro",
|
||||||
|
"nav.settings": "Ustawienia",
|
||||||
"nav.main": "Główna nawigacja",
|
"nav.main": "Główna nawigacja",
|
||||||
"nav.mobile": "Mobilna nawigacja",
|
"nav.mobile": "Mobilna nawigacja",
|
||||||
"language.label": "Wybierz język",
|
"language.label": "Wybierz język",
|
||||||
@@ -18,6 +19,31 @@ const translations = {
|
|||||||
"theme.change": "Zmień motyw",
|
"theme.change": "Zmień motyw",
|
||||||
"theme.light": "Włącz jasny motyw",
|
"theme.light": "Włącz jasny motyw",
|
||||||
"theme.dark": "Włącz ciemny motyw",
|
"theme.dark": "Włącz ciemny motyw",
|
||||||
|
"settings.section": "Preferencje",
|
||||||
|
"settings.title": "Ustawienia",
|
||||||
|
"settings.description": "Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.",
|
||||||
|
"settings.language.title": "Język",
|
||||||
|
"settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
|
||||||
|
"settings.theme.title": "Motyw",
|
||||||
|
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
|
||||||
|
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo",
|
||||||
|
"settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
|
||||||
|
"settings.notifications.regionTitle": "Obszar alertów",
|
||||||
|
"settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.",
|
||||||
|
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
|
||||||
|
"settings.notifications.regionManual": "Wybierz województwo ręcznie",
|
||||||
|
"settings.notifications.noProvince": "brak wybranego województwa",
|
||||||
|
"settings.notifications.deviceTitle": "To urządzenie",
|
||||||
|
"settings.notifications.enable": "Alerty IMGW",
|
||||||
|
"settings.notifications.enableDescription": "Preferencja zostanie użyta po dodaniu backendowej subskrypcji Web Push.",
|
||||||
|
"settings.notifications.requestPermission": "Sprawdź zgodę",
|
||||||
|
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
|
||||||
|
"settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.",
|
||||||
|
"settings.notifications.statusNeedsInstall": "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
|
||||||
|
"settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.",
|
||||||
|
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
|
||||||
|
"settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia.",
|
||||||
|
"settings.notifications.implementationNote": "To pierwszy etap konfiguracji. Wysyłka po nowych ostrzeżeniach IMGW wymaga jeszcze zapisania subskrypcji Web Push i cyklicznego sprawdzania API IMGW po stronie serwera.",
|
||||||
"pwa.install": "Zainstaluj",
|
"pwa.install": "Zainstaluj",
|
||||||
"common.noData": "Brak danych",
|
"common.noData": "Brak danych",
|
||||||
"common.loading": "Ładowanie danych",
|
"common.loading": "Ładowanie danych",
|
||||||
@@ -196,6 +222,7 @@ const translations = {
|
|||||||
"nav.weather": "Weather",
|
"nav.weather": "Weather",
|
||||||
"nav.warnings": "Warnings",
|
"nav.warnings": "Warnings",
|
||||||
"nav.hydro": "Hydro",
|
"nav.hydro": "Hydro",
|
||||||
|
"nav.settings": "Settings",
|
||||||
"nav.main": "Main navigation",
|
"nav.main": "Main navigation",
|
||||||
"nav.mobile": "Mobile navigation",
|
"nav.mobile": "Mobile navigation",
|
||||||
"language.label": "Select language",
|
"language.label": "Select language",
|
||||||
@@ -204,6 +231,31 @@ const translations = {
|
|||||||
"theme.change": "Change theme",
|
"theme.change": "Change theme",
|
||||||
"theme.light": "Enable light theme",
|
"theme.light": "Enable light theme",
|
||||||
"theme.dark": "Enable dark theme",
|
"theme.dark": "Enable dark theme",
|
||||||
|
"settings.section": "Preferences",
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.description": "Manage language, appearance and preparation for IMGW meteorological warning notifications.",
|
||||||
|
"settings.language.title": "Language",
|
||||||
|
"settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.",
|
||||||
|
"settings.theme.title": "Theme",
|
||||||
|
"settings.theme.description": "Switch the light or dark appearance on this device.",
|
||||||
|
"settings.notifications.title": "Weather warning notifications",
|
||||||
|
"settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
|
||||||
|
"settings.notifications.regionTitle": "Alert area",
|
||||||
|
"settings.notifications.regionDescription": "Current area for future notifications: {province}.",
|
||||||
|
"settings.notifications.regionSelected": "Use the location selected in the app",
|
||||||
|
"settings.notifications.regionManual": "Choose province manually",
|
||||||
|
"settings.notifications.noProvince": "no province selected",
|
||||||
|
"settings.notifications.deviceTitle": "This device",
|
||||||
|
"settings.notifications.enable": "IMGW alerts",
|
||||||
|
"settings.notifications.enableDescription": "This preference will be used after the Web Push backend subscription is added.",
|
||||||
|
"settings.notifications.requestPermission": "Check permission",
|
||||||
|
"settings.notifications.statusChecking": "Checking notification support in this browser.",
|
||||||
|
"settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.",
|
||||||
|
"settings.notifications.statusNeedsInstall": "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
|
||||||
|
"settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.",
|
||||||
|
"settings.notifications.statusGranted": "This device has notification permission.",
|
||||||
|
"settings.notifications.statusReady": "You can ask the system for notification permission.",
|
||||||
|
"settings.notifications.implementationNote": "This is the first configuration stage. Sending alerts for new IMGW warnings still requires saving a Web Push subscription and polling the IMGW API on the server.",
|
||||||
"pwa.install": "Install",
|
"pwa.install": "Install",
|
||||||
"common.noData": "No data",
|
"common.noData": "No data",
|
||||||
"common.loading": "Loading data",
|
"common.loading": "Loading data",
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const provinceLabels: Record<Province, Record<Language, string>> = {
|
|||||||
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" },
|
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PROVINCES = Object.keys(provinceLabels) as Province[];
|
||||||
|
|
||||||
function simplifyProvinceName(value: string) {
|
function simplifyProvinceName(value: string) {
|
||||||
return value
|
return value
|
||||||
.normalize("NFD")
|
.normalize("NFD")
|
||||||
|
|||||||
15
lib/store.ts
15
lib/store.ts
@@ -3,14 +3,23 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { SelectedLocation } from "@/types/location";
|
import type { SelectedLocation } from "@/types/location";
|
||||||
|
import type { Province } from "@/types/province";
|
||||||
|
|
||||||
|
type NotificationProvinceMode = "selected" | "manual";
|
||||||
|
|
||||||
interface WeatherStore {
|
interface WeatherStore {
|
||||||
favorites: string[];
|
favorites: string[];
|
||||||
selectedStationId: string | null;
|
selectedStationId: string | null;
|
||||||
selectedLocation: SelectedLocation | null;
|
selectedLocation: SelectedLocation | null;
|
||||||
|
warningNotificationsEnabled: boolean;
|
||||||
|
warningNotificationProvinceMode: NotificationProvinceMode;
|
||||||
|
warningNotificationProvince: Province | null;
|
||||||
toggleFavorite: (id: string) => void;
|
toggleFavorite: (id: string) => void;
|
||||||
selectStation: (id: string) => void;
|
selectStation: (id: string) => void;
|
||||||
selectLocation: (location: SelectedLocation) => void;
|
selectLocation: (location: SelectedLocation) => void;
|
||||||
|
setWarningNotificationsEnabled: (enabled: boolean) => void;
|
||||||
|
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
|
||||||
|
setWarningNotificationProvince: (province: Province | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWeatherStore = create<WeatherStore>()(
|
export const useWeatherStore = create<WeatherStore>()(
|
||||||
@@ -19,6 +28,9 @@ export const useWeatherStore = create<WeatherStore>()(
|
|||||||
favorites: [],
|
favorites: [],
|
||||||
selectedStationId: null,
|
selectedStationId: null,
|
||||||
selectedLocation: null,
|
selectedLocation: null,
|
||||||
|
warningNotificationsEnabled: false,
|
||||||
|
warningNotificationProvinceMode: "selected",
|
||||||
|
warningNotificationProvince: null,
|
||||||
toggleFavorite: (id) =>
|
toggleFavorite: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
favorites: state.favorites.includes(id)
|
favorites: state.favorites.includes(id)
|
||||||
@@ -27,6 +39,9 @@ export const useWeatherStore = create<WeatherStore>()(
|
|||||||
})),
|
})),
|
||||||
selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
|
selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
|
||||||
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
|
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
|
||||||
|
setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }),
|
||||||
|
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
|
||||||
|
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
|
||||||
}),
|
}),
|
||||||
{ name: "wtr:preferences" },
|
{ name: "wtr:preferences" },
|
||||||
),
|
),
|
||||||
|
|||||||
43
public/sw.js
43
public/sw.js
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_NAME = "wtr-shell-v3";
|
const CACHE_NAME = "wtr-shell-v4";
|
||||||
const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
|
const SHELL = ["/", "/settings", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||||
@@ -32,3 +32,42 @@ self.addEventListener("fetch", (event) => {
|
|||||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/offline"))));
|
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/offline"))));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
const fallbackPayload = {
|
||||||
|
title: "IMGW",
|
||||||
|
body: "Nowe ostrzeżenie meteorologiczne.",
|
||||||
|
url: "/warnings",
|
||||||
|
};
|
||||||
|
let payload = fallbackPayload;
|
||||||
|
if (event.data) {
|
||||||
|
const text = event.data.text();
|
||||||
|
try {
|
||||||
|
payload = { ...fallbackPayload, ...JSON.parse(text) };
|
||||||
|
} catch {
|
||||||
|
payload = { ...fallbackPayload, body: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(payload.title, {
|
||||||
|
body: payload.body,
|
||||||
|
icon: "/icons/icon-192.png",
|
||||||
|
badge: "/icons/icon-192.png",
|
||||||
|
data: { url: payload.url || "/warnings" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = new URL(event.notification.data?.url || "/warnings", self.location.origin).href;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||||
|
const existingClient = clients.find((client) => client.url === targetUrl);
|
||||||
|
if (existingClient) return existingClient.focus();
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user