Compare commits
2 Commits
68fd967f88
...
054d75b1f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 054d75b1f4 | |||
| 531e678922 |
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
WEB_PUSH_VAPID_PUBLIC_KEY=
|
||||||
|
WEB_PUSH_VAPID_PRIVATE_KEY=
|
||||||
|
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||||
|
NOTIFICATIONS_CRON_SECRET=
|
||||||
@@ -4,15 +4,15 @@
|
|||||||
|
|
||||||
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
||||||
|
|
||||||
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React i `web-push`. PWA korzysta z manifestu oraz własnego service workera.
|
||||||
|
|
||||||
Najważniejsze katalogi:
|
Najważniejsze katalogi:
|
||||||
|
|
||||||
- `app/` - routing, layout, globalne style i route handlery proxy.
|
- `app/` - routing, layout, globalne style i route handlery proxy.
|
||||||
- `components/` - komponenty pogody, prognozy, hydro, ostrzeżeń, layoutu, UI i stanów ekranu.
|
- `components/` - komponenty pogody, prognozy, hydro, ostrzeżeń, layoutu, UI i stanów ekranu.
|
||||||
- `hooks/` - hooki TanStack Query.
|
- `hooks/` - hooki TanStack Query.
|
||||||
- `lib/` - fetchery, normalizacja danych, tłumaczenia, helpery i store Zustand.
|
- `lib/` - fetchery, normalizacja danych, tłumaczenia, helpery, Web Push i store Zustand.
|
||||||
- `types/` - typy danych IMGW, prognozy oraz lokalizacji.
|
- `types/` - typy danych IMGW, prognozy, lokalizacji oraz powiadomień.
|
||||||
- `public/` - manifest, service worker i ikony PWA.
|
- `public/` - manifest, service worker i ikony PWA.
|
||||||
|
|
||||||
## Komendy
|
## Komendy
|
||||||
@@ -45,6 +45,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
|
|||||||
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
|
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
|
||||||
- Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
|
- Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
|
||||||
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
|
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
|
||||||
|
- Powiadomienia Web Push o ostrzeżeniach meteo konfiguruj przez `/settings`, `public/sw.js` i route handlery `app/api/notifications/*`. Wymagają kluczy VAPID w zmiennych środowiskowych. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji.
|
||||||
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
||||||
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
||||||
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -21,6 +21,7 @@ Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z mie
|
|||||||
- Lucide React
|
- Lucide React
|
||||||
- TanStack Query
|
- TanStack Query
|
||||||
- Zustand z trwałym stanem `localStorage`
|
- Zustand z trwałym stanem `localStorage`
|
||||||
|
- web-push do wysyłki Web Push
|
||||||
- własny service worker, manifest i offline fallback
|
- własny service worker, manifest i offline fallback
|
||||||
|
|
||||||
## Uruchomienie
|
## Uruchomienie
|
||||||
@@ -115,6 +116,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 +129,19 @@ 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 konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push i zapis subskrypcji urządzenia. Na iOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji. Obecny magazyn subskrypcji działa w pamięci procesu, więc produkcja wymaga podmiany `lib/push-store.ts` na trwałą bazę lub KV.
|
||||||
|
|
||||||
|
Do wysyłki Web Push wymagane są zmienne środowiskowe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEB_PUSH_VAPID_PUBLIC_KEY=
|
||||||
|
WEB_PUSH_VAPID_PRIVATE_KEY=
|
||||||
|
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||||
|
NOTIFICATIONS_CRON_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`.
|
||||||
|
|
||||||
## Wdrożenie na Vercel
|
## Wdrożenie na Vercel
|
||||||
|
|
||||||
1. Umieść repozytorium w serwisie Git.
|
1. Umieść repozytorium w serwisie Git.
|
||||||
|
|||||||
74
app/api/notifications/check/route.ts
Normal file
74
app/api/notifications/check/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
||||||
|
import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store";
|
||||||
|
import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service";
|
||||||
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function getTimestamp(value: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isNaN(timestamp) ? null : timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRelevantWarning(warning: WeatherWarning, now: number) {
|
||||||
|
const validTo = getTimestamp(warning.validTo);
|
||||||
|
return warning.kind === "meteo" && (validTo === null || validTo > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCronAuthorized(request: Request) {
|
||||||
|
const secret = process.env.NOTIFICATIONS_CRON_SECRET;
|
||||||
|
if (!secret) return process.env.NODE_ENV !== "production";
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
const secretHeader = request.headers.get("x-cron-secret");
|
||||||
|
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!isCronAuthorized(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isWebPushConfigured()) {
|
||||||
|
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now));
|
||||||
|
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled);
|
||||||
|
const activeWarningIds = new Set(warnings.map((warning) => warning.id));
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
pruneSentWarnings(activeWarningIds);
|
||||||
|
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
const matchingWarnings = warnings.filter((warning) => warning.provinces.includes(subscription.province));
|
||||||
|
for (const warning of matchingWarnings) {
|
||||||
|
if (hasSentWarning(subscription.endpoint, warning.id)) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendWarningNotification(subscription, warning);
|
||||||
|
markWarningSent(subscription.endpoint, warning.id);
|
||||||
|
sent += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
||||||
|
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
subscriptions: subscriptions.length,
|
||||||
|
warnings: warnings.length,
|
||||||
|
sent,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
});
|
||||||
|
}
|
||||||
59
app/api/notifications/subscriptions/route.ts
Normal file
59
app/api/notifications/subscriptions/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
|
||||||
|
import { isWebPushConfigured } from "@/lib/push-service";
|
||||||
|
import { normalizeProvinceName } from "@/lib/provinces";
|
||||||
|
import type { Language } from "@/lib/i18n";
|
||||||
|
import type { BrowserPushSubscription } from "@/types/notifications";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const subscription = value as Partial<BrowserPushSubscription>;
|
||||||
|
return typeof subscription.endpoint === "string"
|
||||||
|
&& subscription.endpoint.startsWith("https://")
|
||||||
|
&& typeof subscription.keys?.p256dh === "string"
|
||||||
|
&& typeof subscription.keys.auth === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isWebPushConfigured()) {
|
||||||
|
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null) as {
|
||||||
|
subscription?: unknown;
|
||||||
|
province?: unknown;
|
||||||
|
language?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
} | null;
|
||||||
|
const subscription = body?.subscription;
|
||||||
|
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||||
|
const language: Language = body?.language === "en" ? "en" : "pl";
|
||||||
|
|
||||||
|
if (!isBrowserPushSubscription(subscription) || !province) {
|
||||||
|
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
upsertPushSubscription({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
subscription,
|
||||||
|
province,
|
||||||
|
language,
|
||||||
|
enabled: body?.enabled !== false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const body = await request.json().catch(() => null) as { endpoint?: unknown } | null;
|
||||||
|
if (typeof body?.endpoint !== "string" || !body.endpoint) {
|
||||||
|
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
|
||||||
|
}
|
||||||
|
removePushSubscription(body.endpoint);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
11
app/api/notifications/vapid-key/route.ts
Normal file
11
app/api/notifications/vapid-key/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getVapidPublicKey, isWebPushConfigured } from "@/lib/push-service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
configured: isWebPushConfigured(),
|
||||||
|
publicKey: getVapidPublicKey(),
|
||||||
|
});
|
||||||
|
}
|
||||||
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
296
components/settings/settings-page.tsx
Normal file
296
components/settings/settings-page.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"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 { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription } from "@/lib/notification-api";
|
||||||
|
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import type { Province } from "@/types/province";
|
||||||
|
|
||||||
|
type NotificationSupportStatus = "checking" | "unconfigured" | "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 [vapidPublicKey, setVapidPublicKey] = useState<string | null>(null);
|
||||||
|
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
||||||
|
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
||||||
|
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 abortController = new AbortController();
|
||||||
|
const animationFrame = window.requestAnimationFrame(() => {
|
||||||
|
setNotificationStatus(getNotificationSupportStatus());
|
||||||
|
fetchVapidPublicKey(abortController.signal)
|
||||||
|
.then((config) => {
|
||||||
|
setVapidPublicKey(config.publicKey);
|
||||||
|
if (!config.configured) setNotificationStatus("unconfigured");
|
||||||
|
})
|
||||||
|
.catch(() => setNotificationStatus("unconfigured"));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return;
|
||||||
|
const province = effectiveProvince;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function syncSubscriptionProvince() {
|
||||||
|
const registration = await navigator.serviceWorker?.getRegistration();
|
||||||
|
const subscription = await registration?.pushManager.getSubscription();
|
||||||
|
if (!subscription || cancelled) return;
|
||||||
|
await savePushSubscription(subscription, province, language).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSubscriptionProvince();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]);
|
||||||
|
|
||||||
|
const notificationStatusLabel = useMemo(() => {
|
||||||
|
switch (notificationStatus) {
|
||||||
|
case "unconfigured":
|
||||||
|
return t("settings.notifications.statusUnconfigured");
|
||||||
|
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 canUsePush = Boolean(vapidPublicKey && 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableWarningNotifications() {
|
||||||
|
if (!vapidPublicKey || !effectiveProvince) {
|
||||||
|
setNotificationMessage(t("settings.notifications.saveError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!("Notification" in window)) {
|
||||||
|
setNotificationStatus("unsupported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSavingSubscription(true);
|
||||||
|
setNotificationMessage(null);
|
||||||
|
try {
|
||||||
|
const permission = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
|
||||||
|
setNotificationStatus(permission);
|
||||||
|
if (permission !== "granted") {
|
||||||
|
setNotificationsEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const registration = await getServiceWorkerRegistration();
|
||||||
|
const existingSubscription = await registration.pushManager.getSubscription();
|
||||||
|
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||||
|
});
|
||||||
|
await savePushSubscription(subscription, effectiveProvince, language);
|
||||||
|
setNotificationsEnabled(true);
|
||||||
|
setNotificationMessage(t("settings.notifications.saveSuccess"));
|
||||||
|
} catch {
|
||||||
|
setNotificationsEnabled(false);
|
||||||
|
setNotificationMessage(t("settings.notifications.saveError"));
|
||||||
|
} finally {
|
||||||
|
setIsSavingSubscription(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableWarningNotifications() {
|
||||||
|
setIsSavingSubscription(true);
|
||||||
|
setNotificationMessage(null);
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker?.getRegistration();
|
||||||
|
const subscription = await registration?.pushManager.getSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
await deletePushSubscription(subscription.endpoint).catch(() => undefined);
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
setNotificationsEnabled(false);
|
||||||
|
setNotificationMessage(t("settings.notifications.disableSuccess"));
|
||||||
|
} catch {
|
||||||
|
setNotificationMessage(t("settings.notifications.saveError"));
|
||||||
|
} finally {
|
||||||
|
setIsSavingSubscription(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 text-sm">
|
||||||
|
<p className="font-medium">{t("settings.notifications.enable")}</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</p>
|
||||||
|
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
|
||||||
|
<Bell className="size-4" />
|
||||||
|
{isSavingSubscription
|
||||||
|
? t("settings.notifications.saving")
|
||||||
|
: notificationsEnabled
|
||||||
|
? t("settings.notifications.disable")
|
||||||
|
: t("settings.notifications.enableDevice")}
|
||||||
|
</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;
|
||||||
|
|||||||
66
lib/i18n.tsx
66
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,38 @@ 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": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.",
|
||||||
|
"settings.notifications.enableDevice": "Włącz na tym urządzeniu",
|
||||||
|
"settings.notifications.disable": "Wyłącz na tym urządzeniu",
|
||||||
|
"settings.notifications.saving": "Zapisuję…",
|
||||||
|
"settings.notifications.saveSuccess": "Powiadomienia są włączone dla tego urządzenia.",
|
||||||
|
"settings.notifications.disableSuccess": "Powiadomienia są wyłączone dla tego urządzenia.",
|
||||||
|
"settings.notifications.saveError": "Nie udało się zapisać subskrypcji powiadomień.",
|
||||||
|
"settings.notifications.requestPermission": "Sprawdź zgodę",
|
||||||
|
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
|
||||||
|
"settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.",
|
||||||
|
"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": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW przez Web Push. W aktualnej wersji subskrypcje są przechowywane w pamięci procesu, więc produkcja wymaga podmiany magazynu na trwałą bazę lub KV.",
|
||||||
"pwa.install": "Zainstaluj",
|
"pwa.install": "Zainstaluj",
|
||||||
"common.noData": "Brak danych",
|
"common.noData": "Brak danych",
|
||||||
"common.loading": "Ładowanie danych",
|
"common.loading": "Ładowanie danych",
|
||||||
@@ -196,6 +229,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 +238,38 @@ 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": "The subscription will be saved for the selected province and used by the server warning checker.",
|
||||||
|
"settings.notifications.enableDevice": "Enable on this device",
|
||||||
|
"settings.notifications.disable": "Disable on this device",
|
||||||
|
"settings.notifications.saving": "Saving…",
|
||||||
|
"settings.notifications.saveSuccess": "Notifications are enabled for this device.",
|
||||||
|
"settings.notifications.disableSuccess": "Notifications are disabled for this device.",
|
||||||
|
"settings.notifications.saveError": "Unable to save the notification subscription.",
|
||||||
|
"settings.notifications.requestPermission": "Check permission",
|
||||||
|
"settings.notifications.statusChecking": "Checking notification support in this browser.",
|
||||||
|
"settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.",
|
||||||
|
"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": "The server checker sends new IMGW meteorological warnings through Web Push. This version stores subscriptions in process memory, so production needs a durable database or KV store.",
|
||||||
"pwa.install": "Install",
|
"pwa.install": "Install",
|
||||||
"common.noData": "No data",
|
"common.noData": "No data",
|
||||||
"common.loading": "Loading data",
|
"common.loading": "Loading data",
|
||||||
|
|||||||
43
lib/notification-api.ts
Normal file
43
lib/notification-api.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Language } from "@/lib/i18n";
|
||||||
|
import type { Province } from "@/types/province";
|
||||||
|
|
||||||
|
interface VapidKeyResponse {
|
||||||
|
configured: boolean;
|
||||||
|
publicKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVapidPublicKey(signal?: AbortSignal) {
|
||||||
|
const response = await fetch("/api/notifications/vapid-key", { signal });
|
||||||
|
if (!response.ok) throw new Error("Unable to load Web Push configuration.");
|
||||||
|
return response.json() as Promise<VapidKeyResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language) {
|
||||||
|
const response = await fetch("/api/notifications/subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
subscription: subscription.toJSON(),
|
||||||
|
province,
|
||||||
|
language,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Unable to save push subscription.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePushSubscription(endpoint: string) {
|
||||||
|
const response = await fetch("/api/notifications/subscriptions", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Unable to delete push subscription.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBase64UrlKey(value: string) {
|
||||||
|
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||||
|
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = window.atob(base64);
|
||||||
|
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
71
lib/push-service.ts
Normal file
71
lib/push-service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import webPush, { type PushSubscription } from "web-push";
|
||||||
|
import type { Language } from "@/lib/i18n";
|
||||||
|
import { formatProvinceName } from "@/lib/provinces";
|
||||||
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
|
import type { WarningPushSubscription } from "@/types/notifications";
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY;
|
||||||
|
const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY;
|
||||||
|
const VAPID_SUBJECT = process.env.WEB_PUSH_VAPID_SUBJECT ?? "mailto:alerts@wtr.local";
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
export function getVapidPublicKey() {
|
||||||
|
return VAPID_PUBLIC_KEY ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebPushConfigured() {
|
||||||
|
return Boolean(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWebPushConfigured() {
|
||||||
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) throw new Error("Web Push VAPID keys are not configured.");
|
||||||
|
if (!configured) {
|
||||||
|
webPush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||||
|
configured = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWarningLevelLabel(warning: WeatherWarning, language: Language) {
|
||||||
|
if (warning.level === null) return language === "pl" ? "stopień nieokreślony" : "level not specified";
|
||||||
|
return language === "pl" ? `stopień ${warning.level}` : `level ${warning.level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWarningValidity(warning: WeatherWarning, language: Language) {
|
||||||
|
if (!warning.validFrom && !warning.validTo) return null;
|
||||||
|
const locale = language === "pl" ? "pl-PL" : "en-GB";
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const from = warning.validFrom ? formatter.format(new Date(warning.validFrom)) : null;
|
||||||
|
const to = warning.validTo ? formatter.format(new Date(warning.validTo)) : null;
|
||||||
|
if (from && to) return language === "pl" ? `od ${from} do ${to}` : `from ${from} to ${to}`;
|
||||||
|
if (from) return language === "pl" ? `od ${from}` : `from ${from}`;
|
||||||
|
return language === "pl" ? `do ${to}` : `until ${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) {
|
||||||
|
const province = formatProvinceName(preference.province, preference.language);
|
||||||
|
const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning");
|
||||||
|
const validity = formatWarningValidity(warning, preference.language);
|
||||||
|
const level = getWarningLevelLabel(warning, preference.language);
|
||||||
|
const bodyParts = preference.language === "pl"
|
||||||
|
? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
|
||||||
|
: [`${province}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `IMGW: ${title}`,
|
||||||
|
body: bodyParts.filter(Boolean).join(" · "),
|
||||||
|
url: "/warnings",
|
||||||
|
warningId: warning.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWarningNotification(preference: WarningPushSubscription, warning: WeatherWarning) {
|
||||||
|
ensureWebPushConfigured();
|
||||||
|
const payload = JSON.stringify(buildWarningPayload(preference, warning));
|
||||||
|
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
||||||
|
}
|
||||||
51
lib/push-store.ts
Normal file
51
lib/push-store.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { WarningPushSubscription } from "@/types/notifications";
|
||||||
|
|
||||||
|
interface PushStore {
|
||||||
|
subscriptions: Map<string, WarningPushSubscription>;
|
||||||
|
sentWarningIds: Map<string, Set<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore };
|
||||||
|
|
||||||
|
function getStore() {
|
||||||
|
if (!globalStore.__wtrPushStore) {
|
||||||
|
globalStore.__wtrPushStore = {
|
||||||
|
subscriptions: new Map(),
|
||||||
|
sentWarningIds: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return globalStore.__wtrPushStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||||
|
getStore().subscriptions.set(subscription.endpoint, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePushSubscription(endpoint: string) {
|
||||||
|
const store = getStore();
|
||||||
|
store.subscriptions.delete(endpoint);
|
||||||
|
store.sentWarningIds.delete(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPushSubscriptions() {
|
||||||
|
return Array.from(getStore().subscriptions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSentWarning(endpoint: string, warningId: string) {
|
||||||
|
return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markWarningSent(endpoint: string, warningId: string) {
|
||||||
|
const store = getStore();
|
||||||
|
const sentIds = store.sentWarningIds.get(endpoint) ?? new Set<string>();
|
||||||
|
sentIds.add(warningId);
|
||||||
|
store.sentWarningIds.set(endpoint, sentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneSentWarnings(activeWarningIds: Set<string>) {
|
||||||
|
getStore().sentWarningIds.forEach((sentIds) => {
|
||||||
|
sentIds.forEach((warningId) => {
|
||||||
|
if (!activeWarningIds.has(warningId)) sentIds.delete(warningId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
15
lib/server-warnings.ts
Normal file
15
lib/server-warnings.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { normalizeWarning } from "@/lib/weather-utils";
|
||||||
|
import type { RawWarning, WeatherWarning } from "@/types/imgw";
|
||||||
|
|
||||||
|
const IMGW_WARNINGS_METEO_URL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo";
|
||||||
|
|
||||||
|
export async function fetchMeteoWarnings(signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||||
|
const response = await fetch(IMGW_WARNINGS_METEO_URL, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Unable to load IMGW meteorological warnings.");
|
||||||
|
const rows = await response.json() as RawWarning[];
|
||||||
|
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : [];
|
||||||
|
}
|
||||||
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" },
|
||||||
),
|
),
|
||||||
|
|||||||
157
package-lock.json
generated
157
package-lock.json
generated
@@ -18,12 +18,14 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
@@ -1569,6 +1571,16 @@
|
|||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.60.0",
|
"version": "8.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||||
@@ -2217,6 +2229,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -2455,6 +2476,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@@ -2577,6 +2610,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
@@ -2635,6 +2674,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -3072,7 +3117,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -3187,6 +3231,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.364",
|
"version": "1.5.364",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
||||||
@@ -4338,6 +4391,28 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4385,6 +4460,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -4959,6 +5040,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5112,6 +5214,12 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -5129,7 +5237,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -5154,7 +5261,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
@@ -6135,6 +6241,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -6170,6 +6296,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -7121,6 +7253,25 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -19,12 +19,14 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
|
|||||||
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
23
types/notifications.ts
Normal file
23
types/notifications.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Language } from "@/lib/i18n";
|
||||||
|
import type { Province } from "@/types/province";
|
||||||
|
|
||||||
|
export interface PushSubscriptionKeys {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserPushSubscription {
|
||||||
|
endpoint: string;
|
||||||
|
expirationTime?: number | null;
|
||||||
|
keys: PushSubscriptionKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarningPushSubscription {
|
||||||
|
endpoint: string;
|
||||||
|
subscription: BrowserPushSubscription;
|
||||||
|
province: Province;
|
||||||
|
language: Language;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user