feat: build production-ready wtr weather PWA
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
88
README.md
Normal file
88
README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# wtr.
|
||||||
|
|
||||||
|
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
||||||
|
|
||||||
|
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW. Aplikacja prezentuje bieżące odczyty synoptyczne, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js z App Router i TypeScript
|
||||||
|
- Tailwind CSS oraz komponenty w stylu shadcn/ui
|
||||||
|
- Framer Motion
|
||||||
|
- Recharts
|
||||||
|
- Lucide React
|
||||||
|
- TanStack Query
|
||||||
|
- Zustand z trwałym stanem `localStorage`
|
||||||
|
- własny service worker, manifest i offline fallback
|
||||||
|
|
||||||
|
## Uruchomienie
|
||||||
|
|
||||||
|
Wymagany jest Node.js 20.9 lub nowszy.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Aplikacja będzie dostępna pod adresem `http://localhost:3000`.
|
||||||
|
|
||||||
|
Sprawdzenie jakości i build produkcyjny:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dane IMGW
|
||||||
|
|
||||||
|
Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW:
|
||||||
|
|
||||||
|
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
|
||||||
|
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
|
||||||
|
- dane hydrologiczne: `https://danepubliczne.imgw.pl/api/data/hydro/`
|
||||||
|
- ostrzeżenia meteorologiczne: `https://danepubliczne.imgw.pl/api/data/warningsmeteo`
|
||||||
|
- ostrzeżenia hydrologiczne: `https://danepubliczne.imgw.pl/api/data/warningshydro`
|
||||||
|
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
|
||||||
|
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
|
||||||
|
|
||||||
|
Przeglądarka pobiera dane przez whitelistowane proxy w `app/api/imgw/[...path]/route.ts`. Pozwala to ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content.
|
||||||
|
|
||||||
|
## Ograniczenia API
|
||||||
|
|
||||||
|
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
||||||
|
|
||||||
|
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
|
||||||
|
|
||||||
|
## Struktura projektu
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/ routing, layout, proxy IMGW, offline fallback
|
||||||
|
components/dashboard dashboard aplikacji
|
||||||
|
components/weather/ hero, stacje, metryki i szczegóły
|
||||||
|
components/warnings/ alerty meteo i hydro
|
||||||
|
components/hydro/ lista stacji hydrologicznych
|
||||||
|
components/ui/ bazowe komponenty interfejsu
|
||||||
|
components/states/ loading, empty i error states
|
||||||
|
hooks/ zapytania TanStack Query
|
||||||
|
lib/ API, normalizacja, helpery i stan
|
||||||
|
types/ typy danych IMGW
|
||||||
|
public/ manifest, ikony i service worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## PWA i offline
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Wdrożenie na Vercel
|
||||||
|
|
||||||
|
1. Umieść repozytorium w serwisie Git.
|
||||||
|
2. Importuj projekt do Vercel jako aplikację Next.js.
|
||||||
|
3. Nie dodawaj kluczy API: publiczne endpointy IMGW ich nie wymagają.
|
||||||
|
4. Wdróż standardowym poleceniem builda `npm run build`.
|
||||||
|
|
||||||
|
Proxy IMGW działa jako route handler Next.js i jest zgodne z hostingiem Vercel.
|
||||||
|
|
||||||
|
## Bezpieczeństwo zależności
|
||||||
|
|
||||||
|
Projekt używa stabilnego Next.js `16.2.6`. `npm audit --omit=dev` raportuje obecnie umiarkowane zgłoszenie `GHSA-qx2v-qp2m-jg93` dla PostCSS `8.4.31` bundlowanego bezpośrednio przez najnowszy Next.js. Główna konfiguracja projektu korzysta z poprawionego PostCSS `8.5.x`, ale wewnętrznej kopii Next.js nie należy ręcznie podmieniać. Po publikacji poprawki upstream należy zaktualizować Next.js i ponownie uruchomić audyt.
|
||||||
39
app/api/imgw/[...path]/route.ts
Normal file
39
app/api/imgw/[...path]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const ALLOWED_PATHS = new Set([
|
||||||
|
"synop",
|
||||||
|
"hydro",
|
||||||
|
"meteo",
|
||||||
|
"warningsmeteo",
|
||||||
|
"warningshydro",
|
||||||
|
"product",
|
||||||
|
]);
|
||||||
|
const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data";
|
||||||
|
|
||||||
|
export async function GET(_: Request, context: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await context.params;
|
||||||
|
const [resource, variant, value] = path;
|
||||||
|
|
||||||
|
const isAllowedSynopDetail = resource === "synop" && variant === "id" && Boolean(value) && path.length === 3;
|
||||||
|
const isAllowedCollection = ALLOWED_PATHS.has(resource) && path.length === 1;
|
||||||
|
if (!isAllowedSynopDetail && !isAllowedCollection) {
|
||||||
|
return NextResponse.json({ error: "Nieobsługiwana ścieżka IMGW." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamPath = path.map(encodeURIComponent).join("/");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${IMGW_BASE_URL}/${upstreamPath}`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "IMGW API jest chwilowo niedostępne." }, { status: response.status });
|
||||||
|
}
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Nie udało się połączyć z IMGW API." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/globals.css
Normal file
53
app/globals.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #eef5fb;
|
||||||
|
color: #102238;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background: #07111f;
|
||||||
|
color: #edf7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.glass {
|
||||||
|
@apply border border-white/35 bg-white/45 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-subtle {
|
||||||
|
@apply border border-white/25 bg-white/25 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/hydro/page.tsx
Normal file
8
app/hydro/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { HydroPage } from "@/components/hydro/hydro-page";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Hydro" };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <HydroPage />;
|
||||||
|
}
|
||||||
50
app/layout.tsx
Normal file
50
app/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import { Providers } from "@/components/layout/providers";
|
||||||
|
import "@/app/globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin", "latin-ext"], variable: "--font-inter" });
|
||||||
|
const themeScript = `
|
||||||
|
try {
|
||||||
|
const storedTheme = localStorage.getItem("wtr:theme");
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
document.documentElement.classList.toggle("dark", storedTheme ? storedTheme === "dark" : prefersDark);
|
||||||
|
} catch {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { default: "wtr. | Pogoda z danych IMGW", template: "%s | wtr." },
|
||||||
|
description: "wtr. to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW.",
|
||||||
|
applicationName: "wtr.",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." },
|
||||||
|
icons: {
|
||||||
|
icon: [{ url: "/icons/icon.svg", type: "image/svg+xml" }, { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
|
||||||
|
apple: "/icons/icon-192.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
themeColor: [
|
||||||
|
{ media: "(prefers-color-scheme: light)", color: "#e8f4fb" },
|
||||||
|
{ media: "(prefers-color-scheme: dark)", color: "#07111f" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
|
||||||
|
<body className={`${inter.variable} font-sans`}>
|
||||||
|
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script>
|
||||||
|
<Providers>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/offline/page.tsx
Normal file
13
app/offline/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { WifiOff } from "lucide-react";
|
||||||
|
|
||||||
|
export default function OfflinePage() {
|
||||||
|
return (
|
||||||
|
<section className="glass mx-auto mt-12 max-w-lg rounded-[2rem] p-8 text-center">
|
||||||
|
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-sky-500/10 text-sky-700 dark:text-sky-300"><WifiOff className="size-6" /></div>
|
||||||
|
<h1 className="mt-5 text-2xl font-semibold tracking-tight">Brak połączenia</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.</p>
|
||||||
|
<Link href="/" className="mt-6 inline-flex rounded-full bg-slate-950 px-4 py-2.5 text-sm font-medium text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:bg-white dark:text-slate-950">Wróć do aplikacji</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardPage } from "@/components/dashboard/dashboard-page";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
6
app/station/[id]/page.tsx
Normal file
6
app/station/[id]/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { StationDetailPage } from "@/components/weather/station-detail-page";
|
||||||
|
|
||||||
|
export default async function StationPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
return <StationDetailPage id={id} />;
|
||||||
|
}
|
||||||
17
app/warnings/page.tsx
Normal file
17
app/warnings/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { WarningsPanel } from "@/components/warnings/warnings-panel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ostrzeżenia" };
|
||||||
|
|
||||||
|
export default function WarningsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Komunikaty IMGW</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Ostrzeżenia</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.</p>
|
||||||
|
</div>
|
||||||
|
<WarningsPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/charts/snapshot-chart.tsx
Normal file
33
components/charts/snapshot-chart.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||||
|
const rows = [
|
||||||
|
{ name: "Wilgotność", value: station.humidity, unit: "%", max: 100, color: "#38bdf8" },
|
||||||
|
{ name: "Wiatr", value: station.windSpeed, unit: "m/s", max: 20, color: "#818cf8" },
|
||||||
|
{ name: "Opad", value: station.rainfall, unit: "mm", max: 30, color: "#22d3ee" },
|
||||||
|
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Snapshot pomiarowy</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">Aktualne proporcje parametrów</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.</p>
|
||||||
|
<div className="mt-5 h-52 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
||||||
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
|
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
||||||
|
<Tooltip cursor={{ fill: "rgba(148,163,184,0.08)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
||||||
|
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
||||||
|
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/dashboard/dashboard-page.tsx
Normal file
28
components/dashboard/dashboard-page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DEFAULT_STATION_NAME } from "@/lib/constants";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import { useWeatherStations } from "@/hooks/use-weather-stations";
|
||||||
|
import { WeatherHero } from "@/components/weather/weather-hero";
|
||||||
|
import { FavoritesSection } from "@/components/weather/favorites-section";
|
||||||
|
import { StationsExplorer } from "@/components/weather/stations-explorer";
|
||||||
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: stations, isPending, isError, refetch } = useWeatherStations();
|
||||||
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
|
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać listy stacji synoptycznych IMGW." />;
|
||||||
|
const selectedStation = stations.find((station) => station.id === selectedStationId)
|
||||||
|
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||||
|
?? stations[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<WeatherHero station={selectedStation} />
|
||||||
|
<FavoritesSection stations={stations} />
|
||||||
|
<StationsExplorer stations={stations} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/hydro/hydro-page.tsx
Normal file
46
components/hydro/hydro-page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Search, Waves } from "lucide-react";
|
||||||
|
import { useHydroStations } from "@/hooks/use-hydro";
|
||||||
|
import { HydroStationCard } from "@/components/hydro/hydro-station-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 48;
|
||||||
|
|
||||||
|
export function HydroPage() {
|
||||||
|
const { data: stations, isPending, isError, refetch } = useHydroStations();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
|
const filteredStations = useMemo(() => (stations ?? []).filter((station) => {
|
||||||
|
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase("pl");
|
||||||
|
return haystack.includes(query.trim().toLocaleLowerCase("pl"));
|
||||||
|
}), [query, stations]);
|
||||||
|
|
||||||
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
|
if (isError) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać stacji hydrologicznych IMGW." />;
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">Monitoring wód IMGW</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Hydro</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.</p>
|
||||||
|
</div>
|
||||||
|
<label className="glass relative block rounded-[1.5rem] p-3">
|
||||||
|
<span className="sr-only">Szukaj stacji hydrologicznej</span>
|
||||||
|
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder="Szukaj stacji, rzeki lub województwa…" className="w-full rounded-2xl border border-white/40 bg-white/45 py-3 pl-10 pr-4 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/5" />
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Znaleziono {filteredStations.length} stacji. Wyświetlono {Math.min(visibleCount, filteredStations.length)}.</p>
|
||||||
|
{!filteredStations.length ? <EmptyState icon={Waves} title="Brak pasujących stacji" description="Zmień wyszukiwaną nazwę stacji, rzeki lub województwa." /> : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
|
||||||
|
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>Pokaż więcej stacji</Button></div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/hydro/hydro-station-card.tsx
Normal file
37
components/hydro/hydro-station-card.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Activity, Droplets, MapPin, Thermometer } from "lucide-react";
|
||||||
|
import type { HydroStation } from "@/types/imgw";
|
||||||
|
import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) {
|
||||||
|
return (
|
||||||
|
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
||||||
|
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-white/60 dark:hover:bg-slate-900/45">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold tracking-tight">{station.name}</h2>
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400"><MapPin className="size-3" />{station.river ?? "Rzeka: brak danych"}{station.province ? ` · ${station.province}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||||
|
<HydroMetric icon={Droplets} label="Poziom" value={formatWaterLevel(station.waterLevel)} />
|
||||||
|
<HydroMetric icon={Thermometer} label="Woda" value={formatTemperature(station.waterTemperature)} />
|
||||||
|
<HydroMetric icon={Activity} label="Przepływ" value={formatFlow(station.flow)} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-[0.7rem] text-slate-500 dark:text-slate-400">Pomiar poziomu: {formatDateTime(station.waterLevelMeasuredAt)}</p>
|
||||||
|
</Card>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-white/35 p-2.5 dark:bg-white/5">
|
||||||
|
<p className="flex items-center gap-1 text-[0.65rem] text-slate-500 dark:text-slate-400"><Icon className="size-3" />{label}</p>
|
||||||
|
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/layout/app-shell.tsx
Normal file
53
components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { CloudSun, Droplets, TriangleAlert } from "lucide-react";
|
||||||
|
import { NAV_ITEMS } from "@/lib/constants";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { InstallPWAButton } from "@/components/ui/install-pwa-button";
|
||||||
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
|
|
||||||
|
const icons = [CloudSun, TriangleAlert, Droplets];
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.28),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(129,140,248,0.18),transparent_31%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(14,116,144,0.22),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(49,46,129,0.22),transparent_31%)]">
|
||||||
|
<header className="sticky top-0 z-40 border-b border-white/25 bg-white/30 backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-white">
|
||||||
|
wtr<span className="text-sky-600 dark:text-sky-300">.</span>
|
||||||
|
</Link>
|
||||||
|
<nav aria-label="Główna nawigacja" className="hidden items-center gap-1 md:flex">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href} className={cn("rounded-full px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-white/60 text-slate-950 shadow-sm dark:bg-white/15 dark:text-white" : "text-slate-600 hover:bg-white/35 dark:text-slate-300 dark:hover:bg-white/10")}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InstallPWAButton />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
|
||||||
|
<nav aria-label="Mobilna nawigacja" className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-[1.4rem] border border-white/40 bg-white/65 p-1.5 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65 md:hidden">
|
||||||
|
{NAV_ITEMS.map((item, index) => {
|
||||||
|
const Icon = icons[index];
|
||||||
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-2xl px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-slate-950 text-white dark:bg-white dark:text-slate-950" : "text-slate-600 dark:text-slate-300")}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/layout/providers.tsx
Normal file
15
components/layout/providers.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type PropsWithChildren } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ServiceWorkerRegister } from "@/components/layout/service-worker-register";
|
||||||
|
|
||||||
|
export function Providers({ children }: PropsWithChildren) {
|
||||||
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<ServiceWorkerRegister />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
components/layout/service-worker-register.tsx
Normal file
12
components/layout/service-worker-register.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if ("serviceWorker" in navigator && process.env.NODE_ENV === "production") {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
13
components/states/empty-state.tsx
Normal file
13
components/states/empty-state.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { CircleCheckBig } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
|
||||||
|
return (
|
||||||
|
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div className="mb-4 rounded-full bg-emerald-500/10 p-3 text-emerald-600 dark:text-emerald-300"><Icon className="size-6" /></div>
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<p className="mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/states/error-state.tsx
Normal file
14
components/states/error-state.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { RefreshCw, TriangleAlert } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function ErrorState({ title = "Nie udało się pobrać danych", description = "Sprawdź połączenie i spróbuj ponownie.", onRetry }: { title?: string; description?: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div className="mb-4 rounded-full bg-amber-500/15 p-3 text-amber-700 dark:text-amber-300"><TriangleAlert className="size-6" /></div>
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description}</p>
|
||||||
|
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />Spróbuj ponownie</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/states/loading-skeleton.tsx
Normal file
16
components/states/loading-skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function LoadingSkeleton({ className = "" }: { className?: string }) {
|
||||||
|
return <div className={cn("animate-pulse rounded-[1.75rem] bg-white/40 dark:bg-white/10", className)} aria-label="Ładowanie danych" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5" aria-busy="true">
|
||||||
|
<LoadingSkeleton className="h-[25rem]" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => <LoadingSkeleton className="h-36" key={index} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/ui/button.tsx
Normal file
25
components/ui/button.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-full text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-slate-950 px-4 py-2.5 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200",
|
||||||
|
glass: "border border-white/30 bg-white/30 px-4 py-2.5 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
||||||
|
ghost: "px-3 py-2 text-slate-700 hover:bg-white/50 dark:text-slate-200 dark:hover:bg-white/10",
|
||||||
|
icon: "size-10 border border-white/30 bg-white/30 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, ...props }, ref) => (
|
||||||
|
<button ref={ref} className={cn(buttonVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Button.displayName = "Button";
|
||||||
6
components/ui/card.tsx
Normal file
6
components/ui/card.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("glass rounded-[1.75rem]", className)} {...props} />;
|
||||||
|
}
|
||||||
37
components/ui/install-pwa-button.tsx
Normal file
37
components/ui/install-pwa-button.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPWAButton() {
|
||||||
|
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePrompt = (promptEvent: Event) => {
|
||||||
|
promptEvent.preventDefault();
|
||||||
|
setEvent(promptEvent as BeforeInstallPromptEvent);
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeinstallprompt", handlePrompt);
|
||||||
|
return () => window.removeEventListener("beforeinstallprompt", handlePrompt);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!event) return null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
onClick={async () => {
|
||||||
|
await event.prompt();
|
||||||
|
await event.userChoice;
|
||||||
|
setEvent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Zainstaluj
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/ui/theme-toggle.tsx
Normal file
48
components/ui/theme-toggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Moon, Sun, SunMoon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const syncSystemTheme = () => {
|
||||||
|
if (!window.localStorage.getItem("wtr:theme")) {
|
||||||
|
root.classList.toggle("dark", media.matches);
|
||||||
|
setIsDark(media.matches);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const animationFrame = window.requestAnimationFrame(() => {
|
||||||
|
setIsDark(root.classList.contains("dark"));
|
||||||
|
setMounted(true);
|
||||||
|
});
|
||||||
|
media.addEventListener("change", syncSystemTheme);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
media.removeEventListener("change", syncSystemTheme);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const nextIsDark = !isDark;
|
||||||
|
document.documentElement.classList.toggle("dark", nextIsDark);
|
||||||
|
window.localStorage.setItem("wtr:theme", nextIsDark ? "dark" : "light");
|
||||||
|
setIsDark(nextIsDark);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
type="button"
|
||||||
|
aria-label={!mounted ? "Zmień motyw" : isDark ? "Włącz jasny motyw" : "Włącz ciemny motyw"}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
{!mounted ? <SunMoon className="size-4" /> : isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/warnings/warning-card.tsx
Normal file
35
components/warnings/warning-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { CalendarClock, MapPinned, Waves, CloudLightning } from "lucide-react";
|
||||||
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
|
import { formatDateTime } from "@/lib/weather-utils";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; index?: number }) {
|
||||||
|
const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
|
||||||
|
const level = warning.level;
|
||||||
|
const levelLabel = level === -1 ? "Susza hydrologiczna" : level === null ? "Poziom nieokreślony" : `Stopień ${level}`;
|
||||||
|
const areasLabel = warning.areas.length > 8
|
||||||
|
? `${warning.areas.slice(0, 8).join(", ")} i ${warning.areas.length - 8} więcej`
|
||||||
|
: warning.areas.join("; ");
|
||||||
|
return (
|
||||||
|
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
|
||||||
|
<Card className="h-full overflow-hidden p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="rounded-2xl bg-amber-500/15 p-2.5 text-amber-700 dark:text-amber-300"><Icon className="size-5" /></div>
|
||||||
|
<span className={cn("rounded-full border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-orange-300/40 bg-orange-400/15 text-orange-800 dark:text-orange-200" : "border-amber-300/40 bg-amber-400/15 text-amber-800 dark:text-amber-200")}>{levelLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500 dark:text-slate-400">{warning.kind === "hydro" ? "Hydrologiczne" : "Meteorologiczne"}</p>
|
||||||
|
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title}</h2>
|
||||||
|
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-slate-600 dark:text-slate-300">{warning.description}</p>}
|
||||||
|
<div className="mt-5 space-y-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom)} — {warning.validTo ? formatDateTime(warning.validTo) : "do odwołania"}</p>
|
||||||
|
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || "Obszar nieokreślony"}</p>
|
||||||
|
</div>
|
||||||
|
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-slate-600 dark:text-slate-300">Prawdopodobieństwo: {warning.probability}%</p>}
|
||||||
|
</Card>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/warnings/warnings-panel.tsx
Normal file
15
components/warnings/warnings-panel.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useWarnings } from "@/hooks/use-warnings";
|
||||||
|
import { WarningCard } from "@/components/warnings/warning-card";
|
||||||
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
|
||||||
|
export function WarningsPanel() {
|
||||||
|
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
||||||
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
|
if (isError) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych." />;
|
||||||
|
if (!warnings?.length) return <EmptyState title="Brak aktywnych ostrzeżeń" description="IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych." />;
|
||||||
|
return <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{warnings.map((warning, index) => <WarningCard key={warning.id} warning={warning} index={index} />)}</div>;
|
||||||
|
}
|
||||||
24
components/weather/current-conditions-card.tsx
Normal file
24
components/weather/current-conditions-card.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react";
|
||||||
|
import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, formatTemperature, formatWind, getWindDirection } from "@/lib/weather-utils";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { MetricCard } from "@/components/weather/metric-card";
|
||||||
|
|
||||||
|
export function CurrentConditionsCard({ station }: { station: SynopStation }) {
|
||||||
|
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||||
|
const metrics = [
|
||||||
|
{ icon: Thermometer, label: "Odczuwalna", value: formatTemperature(feelsLike), detail: "Wartość obliczana, gdy warunki na to pozwalają" },
|
||||||
|
{ icon: Droplets, label: "Wilgotność", value: formatHumidity(station.humidity), detail: "Wilgotność względna powietrza" },
|
||||||
|
{ icon: Gauge, label: "Ciśnienie", value: formatPressure(station.pressure), detail: "Ciśnienie atmosferyczne" },
|
||||||
|
{ icon: Wind, label: "Prędkość wiatru", value: formatWind(station.windSpeed), detail: "Bieżący odczyt IMGW" },
|
||||||
|
{ icon: Navigation, label: "Kierunek wiatru", value: station.windDirection === null ? "Brak danych" : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: "Kierunek napływu wiatru" },
|
||||||
|
{ icon: Umbrella, label: "Suma opadu", value: formatRainfall(station.rainfall), detail: "Suma opadu z pomiaru IMGW" },
|
||||||
|
{ icon: CloudSun, label: "Temperatura", value: formatTemperature(station.temperature), detail: "Temperatura powietrza" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{metrics.map((metric, index) => <MetricCard {...metric} index={index} key={metric.label} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WeatherDetailsGrid = CurrentConditionsCard;
|
||||||
23
components/weather/favorites-section.tsx
Normal file
23
components/weather/favorites-section.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Heart } from "lucide-react";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { StationCard } from "@/components/weather/station-card";
|
||||||
|
|
||||||
|
export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
|
||||||
|
const favoriteIds = useWeatherStore((state) => state.favorites);
|
||||||
|
const favorites = stations.filter((station) => favoriteIds.includes(station.id));
|
||||||
|
if (!favorites.length) return (
|
||||||
|
<section className="glass-subtle rounded-[1.75rem] p-5">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 text-rose-500" />Ulubione lokalizacje</div>
|
||||||
|
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">Dodaj stacje do ulubionych, aby mieć ich odczyty pod ręką.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 fill-rose-500 text-rose-500" />Ulubione lokalizacje</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{favorites.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/weather/metric-card.tsx
Normal file
20
components/weather/metric-card.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { icon: LucideIcon; label: string; value: string; detail?: string; index?: number }) {
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
|
||||||
|
<Card className="h-full p-4 sm:p-5">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||||
|
<Icon className="size-4 text-sky-600 dark:text-sky-300" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xl font-semibold tracking-tight">{value}</p>
|
||||||
|
{detail && <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{detail}</p>}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/weather/station-card.tsx
Normal file
44
components/weather/station-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Droplets, Gauge, Heart, Wind } from "lucide-react";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import { formatHumidity, formatPressure, formatTemperature, getWeatherMoodFromData } from "@/lib/weather-utils";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function StationCard({ station, index = 0 }: { station: SynopStation; index?: number }) {
|
||||||
|
const favorites = useWeatherStore((state) => state.favorites);
|
||||||
|
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
||||||
|
const selectStation = useWeatherStore((state) => state.selectStation);
|
||||||
|
const favorite = favorites.includes(station.id);
|
||||||
|
const mood = getWeatherMoodFromData(station);
|
||||||
|
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}>
|
||||||
|
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-white/60 dark:hover:bg-slate-900/45">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500">
|
||||||
|
<p className="truncate text-sm font-semibold">{station.name}</p>
|
||||||
|
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature)}</p>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<WeatherIcon mood={mood} className="size-9 text-sky-600 dark:text-sky-300" />
|
||||||
|
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? `Usuń ${station.name} z ulubionych` : `Dodaj ${station.name} do ulubionych`} onClick={() => toggleFavorite(station.id)}>
|
||||||
|
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-400">
|
||||||
|
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity)}</span>
|
||||||
|
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span>
|
||||||
|
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure).split(" ")[0]}</span>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/weather/station-detail-page.tsx
Normal file
55
components/weather/station-detail-page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Heart, ShieldCheck } from "lucide-react";
|
||||||
|
import { useWeatherStation } from "@/hooks/use-weather-stations";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import { formatDateTime } from "@/lib/weather-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { WeatherHero } from "@/components/weather/weather-hero";
|
||||||
|
import { WeatherDetailsGrid } from "@/components/weather/current-conditions-card";
|
||||||
|
import { SnapshotChart } from "@/components/charts/snapshot-chart";
|
||||||
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
|
||||||
|
export function StationDetailPage({ id }: { id: string }) {
|
||||||
|
const { data: station, isPending, isError, refetch } = useWeatherStation(id);
|
||||||
|
const favoriteIds = useWeatherStore((state) => state.favorites);
|
||||||
|
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
||||||
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
|
if (isError || !station) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać danych wybranej stacji IMGW." />;
|
||||||
|
const favorite = favoriteIds.includes(station.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-2 rounded-full px-1 py-1 text-sm font-medium text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-300"><ArrowLeft className="size-4" />Wszystkie stacje</Link>
|
||||||
|
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
|
||||||
|
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||||
|
{favorite ? "Usuń z ulubionych" : "Dodaj do ulubionych"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<WeatherHero station={station} />
|
||||||
|
<section>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Stacja {station.name}</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Aktualne parametry</h1>
|
||||||
|
<p className="mb-4 mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.</p>
|
||||||
|
<WeatherDetailsGrid station={station} />
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
|
<SnapshotChart station={station} />
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center gap-2 text-sky-700 dark:text-sky-300"><ShieldCheck className="size-5" /><p className="text-xs font-semibold uppercase tracking-[0.18em]">Jakość danych</p></div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold tracking-tight">Ostatni pomiar IMGW</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">Czas poniżej pochodzi bezpośrednio z najnowszego odczytu udostępnionego przez IMGW.</p>
|
||||||
|
<dl className="mt-6 space-y-3 text-sm">
|
||||||
|
<div><dt className="text-slate-500 dark:text-slate-400">Ostatni pomiar</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt)}</dd></div>
|
||||||
|
<div><dt className="text-slate-500 dark:text-slate-400">Źródło</dt><dd className="mt-0.5 font-medium">Publiczne API IMGW</dd></div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
components/weather/station-grid.tsx
Normal file
9
components/weather/station-grid.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { StationCard } from "@/components/weather/station-card";
|
||||||
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
|
import { SearchX } from "lucide-react";
|
||||||
|
|
||||||
|
export function StationGrid({ stations }: { stations: SynopStation[] }) {
|
||||||
|
if (!stations.length) return <EmptyState icon={SearchX} title="Brak pasujących stacji" description="Zmień wyszukiwanie lub wybierz inny filtr." />;
|
||||||
|
return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{stations.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div>;
|
||||||
|
}
|
||||||
35
components/weather/station-search.tsx
Normal file
35
components/weather/station-search.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Search, SlidersHorizontal } from "lucide-react";
|
||||||
|
import type { StationFilter, StationSort } from "@/components/weather/stations-explorer";
|
||||||
|
|
||||||
|
export function StationSearch({ query, onQueryChange, sort, onSortChange, filter, onFilterChange }: { query: string; onQueryChange: (value: string) => void; sort: StationSort; onSortChange: (value: StationSort) => void; filter: StationFilter; onFilterChange: (value: StationFilter) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="glass grid gap-3 rounded-[1.75rem] p-3 sm:grid-cols-[1fr_auto_auto]">
|
||||||
|
<label className="relative">
|
||||||
|
<span className="sr-only">Szukaj stacji synoptycznej</span>
|
||||||
|
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input value={query} onChange={(event) => onQueryChange(event.target.value)} placeholder="Szukaj stacji IMGW…" className="w-full rounded-2xl border border-white/40 bg-white/45 py-3 pl-10 pr-4 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/5" />
|
||||||
|
</label>
|
||||||
|
<label className="relative">
|
||||||
|
<span className="sr-only">Sortowanie stacji</span>
|
||||||
|
<select value={sort} onChange={(event) => onSortChange(event.target.value as StationSort)} className="w-full appearance-none rounded-2xl border border-white/40 bg-white/45 py-3 pl-4 pr-9 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-slate-900/60">
|
||||||
|
<option value="alphabetical">Alfabetycznie</option>
|
||||||
|
<option value="temperature-desc">Temperatura: najwyższa</option>
|
||||||
|
<option value="temperature-asc">Temperatura: najniższa</option>
|
||||||
|
<option value="humidity-desc">Wilgotność: najwyższa</option>
|
||||||
|
<option value="pressure-desc">Ciśnienie: najwyższe</option>
|
||||||
|
</select>
|
||||||
|
<SlidersHorizontal className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="sr-only">Filtr stacji</span>
|
||||||
|
<select value={filter} onChange={(event) => onFilterChange(event.target.value as StationFilter)} className="w-full rounded-2xl border border-white/40 bg-white/45 px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-slate-900/60">
|
||||||
|
<option value="all">Wszystkie stacje</option>
|
||||||
|
<option value="warmest">Najcieplejsze</option>
|
||||||
|
<option value="coldest">Najzimniejsze</option>
|
||||||
|
<option value="windy">Największy wiatr</option>
|
||||||
|
<option value="rainy">Największy opad</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
components/weather/stations-explorer.tsx
Normal file
45
components/weather/stations-explorer.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { StationGrid } from "@/components/weather/station-grid";
|
||||||
|
import { StationSearch } from "@/components/weather/station-search";
|
||||||
|
|
||||||
|
export type StationSort = "alphabetical" | "temperature-desc" | "temperature-asc" | "humidity-desc" | "pressure-desc";
|
||||||
|
export type StationFilter = "all" | "warmest" | "coldest" | "windy" | "rainy";
|
||||||
|
|
||||||
|
function compareNumbers(a: number | null, b: number | null, direction: "asc" | "desc") {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return direction === "asc" ? a - b : b - a;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationsExplorer({ stations }: { stations: SynopStation[] }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [sort, setSort] = useState<StationSort>("alphabetical");
|
||||||
|
const [filter, setFilter] = useState<StationFilter>("all");
|
||||||
|
const visibleStations = useMemo(() => {
|
||||||
|
const searched = stations.filter((station) => station.name.toLocaleLowerCase("pl").includes(query.trim().toLocaleLowerCase("pl")));
|
||||||
|
const sorted = [...searched].sort((a, b) => {
|
||||||
|
if (sort === "temperature-desc") return compareNumbers(a.temperature, b.temperature, "desc");
|
||||||
|
if (sort === "temperature-asc") return compareNumbers(a.temperature, b.temperature, "asc");
|
||||||
|
if (sort === "humidity-desc") return compareNumbers(a.humidity, b.humidity, "desc");
|
||||||
|
if (sort === "pressure-desc") return compareNumbers(a.pressure, b.pressure, "desc");
|
||||||
|
return a.name.localeCompare(b.name, "pl");
|
||||||
|
});
|
||||||
|
if (filter === "all") return sorted;
|
||||||
|
const key = { warmest: "temperature", coldest: "temperature", windy: "windSpeed", rainy: "rainfall" }[filter] as keyof SynopStation;
|
||||||
|
return [...sorted].sort((a, b) => compareNumbers(a[key] as number | null, b[key] as number | null, filter === "coldest" ? "asc" : "desc")).slice(0, 12);
|
||||||
|
}, [filter, query, sort, stations]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Stacje synoptyczne</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Pogoda w Polsce</h2>
|
||||||
|
</div>
|
||||||
|
<StationSearch query={query} onQueryChange={setQuery} sort={sort} onSortChange={setSort} filter={filter} onFilterChange={setFilter} />
|
||||||
|
<StationGrid stations={visibleStations} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
components/weather/weather-hero.tsx
Normal file
70
components/weather/weather-hero.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } from "lucide-react";
|
||||||
|
import {
|
||||||
|
calculateFeelsLike,
|
||||||
|
formatDateTime,
|
||||||
|
formatHumidity,
|
||||||
|
formatPressure,
|
||||||
|
formatRainfall,
|
||||||
|
formatTemperature,
|
||||||
|
formatWind,
|
||||||
|
getWeatherDescription,
|
||||||
|
getWeatherMoodFromData,
|
||||||
|
moodGradient,
|
||||||
|
} from "@/lib/weather-utils";
|
||||||
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||||
|
|
||||||
|
export function WeatherHero({ station }: { station: SynopStation }) {
|
||||||
|
const mood = getWeatherMoodFromData(station);
|
||||||
|
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||||
|
const metrics = [
|
||||||
|
{ icon: Droplets, label: "Wilgotność", value: formatHumidity(station.humidity) },
|
||||||
|
{ icon: Wind, label: "Wiatr", value: formatWind(station.windSpeed) },
|
||||||
|
{ icon: Umbrella, label: "Opad", value: formatRainfall(station.rainfall) },
|
||||||
|
{ icon: Gauge, label: "Ciśnienie", value: formatPressure(station.pressure) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.55, ease: "easeOut" }}
|
||||||
|
className={`relative isolate overflow-hidden rounded-[2rem] bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-[0_24px_75px_rgba(15,23,42,0.24)] sm:px-8 sm:py-8 lg:px-10`}
|
||||||
|
>
|
||||||
|
<div className="absolute -right-20 -top-20 size-72 rounded-full bg-white/15 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-24 -left-16 size-72 rounded-full bg-cyan-200/15 blur-3xl" />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{station.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
||||||
|
<div>
|
||||||
|
<div className="text-[5.8rem] font-extralight leading-[0.85] tracking-[-0.11em] sm:text-[8rem]">
|
||||||
|
{formatTemperature(station.temperature)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(station)}</p>
|
||||||
|
<p className="mt-1 text-sm text-white/75">Odczuwalna {formatTemperature(feelsLike)} · pomiar {formatDateTime(station.measuredAt)}</p>
|
||||||
|
</div>
|
||||||
|
<WeatherIcon mood={mood} className="mb-4 size-20 text-white/80 sm:size-28" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
|
||||||
|
{metrics.map(({ icon: Icon, label, value }) => (
|
||||||
|
<div key={label} className="rounded-2xl border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-white/70"><Icon className="size-3.5" />{label}</div>
|
||||||
|
<p className="mt-2 text-base font-semibold">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{station.windDirection !== null && (
|
||||||
|
<p className="mt-4 flex items-center gap-1.5 text-xs text-white/70">
|
||||||
|
<Navigation className="size-3.5" style={{ transform: `rotate(${station.windDirection}deg)` }} />
|
||||||
|
Kierunek wiatru: {station.windDirection}°
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/weather/weather-icon.tsx
Normal file
14
components/weather/weather-icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { CloudRain, CloudSun, MoonStar, Snowflake, Sun, Wind } from "lucide-react";
|
||||||
|
import type { WeatherMood } from "@/types/imgw";
|
||||||
|
|
||||||
|
export function WeatherIcon({ mood, className = "" }: { mood: WeatherMood; className?: string }) {
|
||||||
|
const Icon = {
|
||||||
|
clear: Sun,
|
||||||
|
rain: CloudRain,
|
||||||
|
wind: Wind,
|
||||||
|
cold: Snowflake,
|
||||||
|
night: MoonStar,
|
||||||
|
mild: CloudSun,
|
||||||
|
}[mood];
|
||||||
|
return <Icon className={className} strokeWidth={1.35} />;
|
||||||
|
}
|
||||||
11
eslint.config.mjs
Normal file
11
eslint.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
globalIgnores([".next/**", "node_modules/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
15
hooks/use-hydro.ts
Normal file
15
hooks/use-hydro.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchHydroStations } from "@/lib/imgw-api";
|
||||||
|
import { QUERY_GC_TIME, QUERY_STALE_TIME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function useHydroStations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["hydro-stations"],
|
||||||
|
queryFn: ({ signal }) => fetchHydroStations(signal),
|
||||||
|
staleTime: QUERY_STALE_TIME,
|
||||||
|
gcTime: QUERY_GC_TIME,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
hooks/use-warnings.ts
Normal file
15
hooks/use-warnings.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchWarnings } from "@/lib/imgw-api";
|
||||||
|
import { QUERY_GC_TIME, QUERY_STALE_TIME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function useWarnings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["warnings"],
|
||||||
|
queryFn: ({ signal }) => fetchWarnings(signal),
|
||||||
|
staleTime: QUERY_STALE_TIME,
|
||||||
|
gcTime: QUERY_GC_TIME,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
26
hooks/use-weather-stations.ts
Normal file
26
hooks/use-weather-stations.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchSynopStation, fetchSynopStations } from "@/lib/imgw-api";
|
||||||
|
import { QUERY_GC_TIME, QUERY_STALE_TIME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function useWeatherStations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["synop-stations"],
|
||||||
|
queryFn: ({ signal }) => fetchSynopStations(signal),
|
||||||
|
staleTime: QUERY_STALE_TIME,
|
||||||
|
gcTime: QUERY_GC_TIME,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeatherStation(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["synop-station", id],
|
||||||
|
queryFn: ({ signal }) => fetchSynopStation(id, signal),
|
||||||
|
staleTime: QUERY_STALE_TIME,
|
||||||
|
gcTime: QUERY_GC_TIME,
|
||||||
|
retry: 2,
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
12
lib/constants.ts
Normal file
12
lib/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const DEFAULT_STATION_NAME = "Warszawa";
|
||||||
|
export const APP_NAME = "wtr.";
|
||||||
|
export const APP_TAGLINE = "Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.";
|
||||||
|
|
||||||
|
export const QUERY_STALE_TIME = 5 * 60 * 1000;
|
||||||
|
export const QUERY_GC_TIME = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const NAV_ITEMS = [
|
||||||
|
{ href: "/", label: "Pogoda" },
|
||||||
|
{ href: "/warnings", label: "Ostrzeżenia" },
|
||||||
|
{ href: "/hydro", label: "Hydro" },
|
||||||
|
] as const;
|
||||||
57
lib/imgw-api.ts
Normal file
57
lib/imgw-api.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
normalizeHydroStation,
|
||||||
|
normalizeSynopStation,
|
||||||
|
normalizeWarning,
|
||||||
|
} from "@/lib/weather-utils";
|
||||||
|
import type {
|
||||||
|
HydroStation,
|
||||||
|
RawHydroStation,
|
||||||
|
RawSynopStation,
|
||||||
|
RawWarning,
|
||||||
|
SynopStation,
|
||||||
|
WeatherWarning,
|
||||||
|
WarningKind,
|
||||||
|
} from "@/types/imgw";
|
||||||
|
|
||||||
|
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||||
|
const response = await fetch(`/api/imgw/${path}`, { signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = await response.text().catch(() => "");
|
||||||
|
throw new Error(details || `IMGW API zwróciło status ${response.status}.`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSynopStations(signal?: AbortSignal): Promise<SynopStation[]> {
|
||||||
|
const rows = await getJson<RawSynopStation[]>("synop", signal);
|
||||||
|
return rows.map(normalizeSynopStation).filter((station): station is SynopStation => station !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSynopStation(id: string, signal?: AbortSignal): Promise<SynopStation> {
|
||||||
|
const row = await getJson<RawSynopStation>(`synop/id/${encodeURIComponent(id)}`, signal);
|
||||||
|
const station = normalizeSynopStation(row);
|
||||||
|
if (!station) throw new Error("IMGW zwróciło niekompletne dane stacji.");
|
||||||
|
return station;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHydroStations(signal?: AbortSignal): Promise<HydroStation[]> {
|
||||||
|
const rows = await getJson<RawHydroStation[]>("hydro", signal);
|
||||||
|
return rows.map(normalizeHydroStation).filter((station): station is HydroStation => station !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWarningsByKind(kind: WarningKind, signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||||
|
const rows = await getJson<RawWarning[]>(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal);
|
||||||
|
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
fetchWarningsByKind("meteo", signal),
|
||||||
|
fetchWarningsByKind("hydro", signal),
|
||||||
|
]);
|
||||||
|
const warnings = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
||||||
|
if (results.every((result) => result.status === "rejected")) {
|
||||||
|
throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
|
||||||
|
}
|
||||||
|
return warnings.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""));
|
||||||
|
}
|
||||||
28
lib/store.ts
Normal file
28
lib/store.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
interface WeatherStore {
|
||||||
|
favorites: string[];
|
||||||
|
selectedStationId: string | null;
|
||||||
|
toggleFavorite: (id: string) => void;
|
||||||
|
selectStation: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWeatherStore = create<WeatherStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
favorites: [],
|
||||||
|
selectedStationId: null,
|
||||||
|
toggleFavorite: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
favorites: state.favorites.includes(id)
|
||||||
|
? state.favorites.filter((favoriteId) => favoriteId !== id)
|
||||||
|
: [...state.favorites, id],
|
||||||
|
})),
|
||||||
|
selectStation: (id) => set({ selectedStationId: id }),
|
||||||
|
}),
|
||||||
|
{ name: "wtr:preferences" },
|
||||||
|
),
|
||||||
|
);
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
187
lib/weather-utils.ts
Normal file
187
lib/weather-utils.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import type {
|
||||||
|
HydroStation,
|
||||||
|
RawHydroStation,
|
||||||
|
RawSynopStation,
|
||||||
|
RawWarning,
|
||||||
|
SynopStation,
|
||||||
|
WeatherMood,
|
||||||
|
WeatherWarning,
|
||||||
|
WarningKind,
|
||||||
|
} from "@/types/imgw";
|
||||||
|
|
||||||
|
const polishLocale = "pl-PL";
|
||||||
|
|
||||||
|
export function toNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||||
|
if (typeof value !== "string" || value.trim() === "") return null;
|
||||||
|
const parsed = Number(value.replace(",", "."));
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value?: string | null): string | null {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
const isoCandidate = value.includes("T") ? value : value.replace(" ", "T");
|
||||||
|
const date = new Date(isoCandidate);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function synopMeasuredAt(date?: string | null, hour?: string | null) {
|
||||||
|
if (!date?.trim() || !hour?.trim()) return null;
|
||||||
|
return normalizeDate(`${date}T${hour.padStart(2, "0")}:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSynopStation(raw: RawSynopStation): SynopStation | null {
|
||||||
|
if (!raw.id_stacji?.trim() || !raw.stacja?.trim()) return null;
|
||||||
|
return {
|
||||||
|
id: raw.id_stacji,
|
||||||
|
name: raw.stacja,
|
||||||
|
measuredAt: synopMeasuredAt(raw.data_pomiaru, raw.godzina_pomiaru),
|
||||||
|
temperature: toNumber(raw.temperatura),
|
||||||
|
windSpeed: toNumber(raw.predkosc_wiatru),
|
||||||
|
windDirection: toNumber(raw.kierunek_wiatru),
|
||||||
|
humidity: toNumber(raw.wilgotnosc_wzgledna),
|
||||||
|
rainfall: toNumber(raw.suma_opadu),
|
||||||
|
pressure: toNumber(raw.cisnienie),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null {
|
||||||
|
if (!raw.id_stacji?.trim() || !raw.stacja?.trim()) return null;
|
||||||
|
return {
|
||||||
|
id: raw.id_stacji,
|
||||||
|
name: raw.stacja,
|
||||||
|
river: raw.rzeka?.trim() || null,
|
||||||
|
province: raw.wojewodztwo?.trim() || null,
|
||||||
|
longitude: toNumber(raw.lon),
|
||||||
|
latitude: toNumber(raw.lat),
|
||||||
|
waterLevel: toNumber(raw.stan_wody),
|
||||||
|
waterLevelMeasuredAt: normalizeDate(raw.stan_wody_data_pomiaru),
|
||||||
|
waterTemperature: toNumber(raw.temperatura_wody),
|
||||||
|
waterTemperatureMeasuredAt: normalizeDate(raw.temperatura_wody_data_pomiaru),
|
||||||
|
flow: toNumber(raw.przeplyw),
|
||||||
|
flowMeasuredAt: normalizeDate(raw.przeplyw_data),
|
||||||
|
icePhenomenon: toNumber(raw.zjawisko_lodowe),
|
||||||
|
overgrowthPhenomenon: toNumber(raw.zjawisko_zarastania),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
|
||||||
|
const describedAreas = (raw.obszary ?? [])
|
||||||
|
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
||||||
|
.filter((area): area is string => Boolean(area));
|
||||||
|
const areas = describedAreas.length ? describedAreas : (raw.teryt ?? []).map((code) => `TERYT ${code}`);
|
||||||
|
const title = raw.zdarzenie?.trim() || raw.nazwa_zdarzenia?.trim() || (kind === "meteo" ? "Ostrzeżenie meteorologiczne" : "Ostrzeżenie hydrologiczne");
|
||||||
|
return {
|
||||||
|
id: `${kind}-${raw.id ?? raw.numer ?? index}-${raw.data_od ?? raw.obowiazuje_od ?? "unknown"}`,
|
||||||
|
kind,
|
||||||
|
level: toNumber(raw["stopień"] ?? raw.stopien),
|
||||||
|
title,
|
||||||
|
description: raw.przebieg?.trim() || raw.tresc?.trim() || null,
|
||||||
|
comment: raw.komentarz?.trim() || null,
|
||||||
|
validFrom: normalizeDate(raw.data_od ?? raw.obowiazuje_od),
|
||||||
|
validTo: raw.data_do?.startsWith("9999-") ? null : normalizeDate(raw.data_do ?? raw.obowiazuje_do),
|
||||||
|
publishedAt: normalizeDate(raw.opublikowano),
|
||||||
|
probability: toNumber(raw.prawdopodobienstwo),
|
||||||
|
areas,
|
||||||
|
office: raw.biuro?.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTemperature(value: number | null) {
|
||||||
|
return value === null ? "Brak danych" : `${Math.round(value)}°`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPressure(value: number | null) {
|
||||||
|
return value === null ? "Brak danych" : `${value.toFixed(1)} hPa`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHumidity(value: number | null) {
|
||||||
|
return value === null ? "Brak danych" : `${Math.round(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWind(speed: number | null, direction?: number | null) {
|
||||||
|
if (speed === null) return "Brak danych";
|
||||||
|
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
||||||
|
return `${speed.toFixed(1)} m/s${directionLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRainfall(value: number | null) {
|
||||||
|
return value === null ? "Brak danych" : `${value.toFixed(value < 1 ? 2 : 1)} mm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWaterLevel(value: number | null) {
|
||||||
|
return value === null ? "Brak danych" : `${Math.round(value)} cm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFlow(value: number | null) {
|
||||||
|
return value === null ? "Brak danych" : `${value.toFixed(2)} m³/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null, fallback = "Brak danych") {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(polishLocale, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateFeelsLike(temperature: number | null, humidity: number | null, windSpeed: number | null) {
|
||||||
|
if (temperature === null) return null;
|
||||||
|
if (temperature <= 10 && windSpeed !== null && windSpeed > 1.34) {
|
||||||
|
const windKmh = windSpeed * 3.6;
|
||||||
|
return 13.12 + 0.6215 * temperature - 11.37 * windKmh ** 0.16 + 0.3965 * temperature * windKmh ** 0.16;
|
||||||
|
}
|
||||||
|
if (temperature >= 27 && humidity !== null) {
|
||||||
|
const c1 = -8.78469475556;
|
||||||
|
const c2 = 1.61139411;
|
||||||
|
const c3 = 2.33854883889;
|
||||||
|
const c4 = -0.14611605;
|
||||||
|
const c5 = -0.012308094;
|
||||||
|
const c6 = -0.0164248277778;
|
||||||
|
const c7 = 0.002211732;
|
||||||
|
const c8 = 0.00072546;
|
||||||
|
const c9 = -0.000003582;
|
||||||
|
return c1 + c2 * temperature + c3 * humidity + c4 * temperature * humidity + c5 * temperature ** 2 +
|
||||||
|
c6 * humidity ** 2 + c7 * temperature ** 2 * humidity + c8 * temperature * humidity ** 2 +
|
||||||
|
c9 * temperature ** 2 * humidity ** 2;
|
||||||
|
}
|
||||||
|
return temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindDirection(degrees: number) {
|
||||||
|
const labels = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return labels[Math.round((((degrees % 360) + 360) % 360) / 45) % 8];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherMoodFromData(station: SynopStation, date = new Date()): WeatherMood {
|
||||||
|
const hour = date.getHours();
|
||||||
|
if (hour < 6 || hour >= 21) return "night";
|
||||||
|
if ((station.rainfall ?? 0) >= 0.1) return "rain";
|
||||||
|
if ((station.windSpeed ?? 0) >= 8) return "wind";
|
||||||
|
if ((station.temperature ?? 15) <= 3) return "cold";
|
||||||
|
if ((station.temperature ?? 15) >= 20) return "clear";
|
||||||
|
return "mild";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherDescription(station: SynopStation) {
|
||||||
|
if ((station.rainfall ?? 0) >= 5) return "Wyraźne opady";
|
||||||
|
if ((station.rainfall ?? 0) >= 0.1) return "Opady";
|
||||||
|
if ((station.windSpeed ?? 0) >= 8) return "Silny wiatr";
|
||||||
|
if ((station.humidity ?? 0) >= 90) return "Wilgotno";
|
||||||
|
return "Spokojne warunki";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moodGradient(mood: WeatherMood) {
|
||||||
|
return {
|
||||||
|
clear: "from-sky-500 via-blue-500 to-indigo-700",
|
||||||
|
rain: "from-slate-500 via-slate-600 to-indigo-900",
|
||||||
|
wind: "from-cyan-600 via-slate-500 to-blue-900",
|
||||||
|
cold: "from-cyan-400 via-blue-500 to-indigo-800",
|
||||||
|
night: "from-slate-800 via-indigo-950 to-slate-950",
|
||||||
|
mild: "from-sky-500 via-cyan-600 to-blue-800",
|
||||||
|
}[mood];
|
||||||
|
}
|
||||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7312
package-lock.json
generated
Normal file
7312
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "wtr",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.80.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.0.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"next": "^16.2.6",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-next": "^16.2.6",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
12
public/icons/icon.svg
Normal file
12
public/icons/icon.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="70" y1="44" x2="450" y2="484" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#38bdf8"/>
|
||||||
|
<stop offset=".52" stop-color="#2563eb"/>
|
||||||
|
<stop offset="1" stop-color="#312e81"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="142" fill="url(#g)"/>
|
||||||
|
<circle cx="388" cy="138" r="78" fill="#fff" fill-opacity=".12"/>
|
||||||
|
<text x="62" y="318" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="186" font-weight="700" letter-spacing="-28">wtr.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 606 B |
BIN
public/icons/maskable-512.png
Normal file
BIN
public/icons/maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
12
public/icons/maskable.svg
Normal file
12
public/icons/maskable.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="40" y1="20" x2="480" y2="500" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#38bdf8"/>
|
||||||
|
<stop offset=".55" stop-color="#2563eb"/>
|
||||||
|
<stop offset="1" stop-color="#312e81"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#g)"/>
|
||||||
|
<circle cx="392" cy="124" r="90" fill="#fff" fill-opacity=".12"/>
|
||||||
|
<text x="62" y="318" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="186" font-weight="700" letter-spacing="-28">wtr.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 597 B |
43
public/manifest.json
Normal file
43
public/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "wtr.",
|
||||||
|
"short_name": "wtr.",
|
||||||
|
"description": "Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#07111f",
|
||||||
|
"theme_color": "#0c4a6e",
|
||||||
|
"lang": "pl",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/maskable.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
public/sw.js
Normal file
34
public/sw.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const CACHE_NAME = "wtr-shell-v1";
|
||||||
|
const SHELL = ["/", "/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) => {
|
||||||
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.pathname.startsWith("/api/imgw/")) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/offline"))));
|
||||||
|
}
|
||||||
|
});
|
||||||
22
tailwind.config.ts
Normal file
22
tailwind.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./components/**/*.{ts,tsx}",
|
||||||
|
"./hooks/**/*.{ts,tsx}",
|
||||||
|
"./lib/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glass: "0 18px 55px rgba(15, 23, 42, 0.13)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
105
types/imgw.ts
Normal file
105
types/imgw.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
export interface RawSynopStation {
|
||||||
|
id_stacji?: string | null;
|
||||||
|
stacja?: string | null;
|
||||||
|
data_pomiaru?: string | null;
|
||||||
|
godzina_pomiaru?: string | null;
|
||||||
|
temperatura?: string | null;
|
||||||
|
predkosc_wiatru?: string | null;
|
||||||
|
kierunek_wiatru?: string | null;
|
||||||
|
wilgotnosc_wzgledna?: string | null;
|
||||||
|
suma_opadu?: string | null;
|
||||||
|
cisnienie?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynopStation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
measuredAt: string | null;
|
||||||
|
temperature: number | null;
|
||||||
|
windSpeed: number | null;
|
||||||
|
windDirection: number | null;
|
||||||
|
humidity: number | null;
|
||||||
|
rainfall: number | null;
|
||||||
|
pressure: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawHydroStation {
|
||||||
|
id_stacji?: string | null;
|
||||||
|
stacja?: string | null;
|
||||||
|
rzeka?: string | null;
|
||||||
|
wojewodztwo?: string | null;
|
||||||
|
lon?: string | null;
|
||||||
|
lat?: string | null;
|
||||||
|
stan_wody?: string | null;
|
||||||
|
stan_wody_data_pomiaru?: string | null;
|
||||||
|
temperatura_wody?: string | null;
|
||||||
|
temperatura_wody_data_pomiaru?: string | null;
|
||||||
|
przeplyw?: string | null;
|
||||||
|
przeplyw_data?: string | null;
|
||||||
|
zjawisko_lodowe?: string | null;
|
||||||
|
zjawisko_lodowe_data_pomiaru?: string | null;
|
||||||
|
zjawisko_zarastania?: string | null;
|
||||||
|
zjawisko_zarastania_data_pomiaru?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydroStation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
river: string | null;
|
||||||
|
province: string | null;
|
||||||
|
longitude: number | null;
|
||||||
|
latitude: number | null;
|
||||||
|
waterLevel: number | null;
|
||||||
|
waterLevelMeasuredAt: string | null;
|
||||||
|
waterTemperature: number | null;
|
||||||
|
waterTemperatureMeasuredAt: string | null;
|
||||||
|
flow: number | null;
|
||||||
|
flowMeasuredAt: string | null;
|
||||||
|
icePhenomenon: number | null;
|
||||||
|
overgrowthPhenomenon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawWarningArea {
|
||||||
|
wojewodztwo?: string | null;
|
||||||
|
opis?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawWarning {
|
||||||
|
id?: string | null;
|
||||||
|
opublikowano?: string | null;
|
||||||
|
stopien?: string | null;
|
||||||
|
"stopień"?: string | null;
|
||||||
|
nazwa_zdarzenia?: string | null;
|
||||||
|
data_od?: string | null;
|
||||||
|
data_do?: string | null;
|
||||||
|
obowiazuje_od?: string | null;
|
||||||
|
obowiazuje_do?: string | null;
|
||||||
|
prawdopodobienstwo?: string | null;
|
||||||
|
numer?: string | null;
|
||||||
|
biuro?: string | null;
|
||||||
|
zdarzenie?: string | null;
|
||||||
|
przebieg?: string | null;
|
||||||
|
tresc?: string | null;
|
||||||
|
komentarz?: string | null;
|
||||||
|
obszary?: RawWarningArea[] | null;
|
||||||
|
teryt?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WarningKind = "meteo" | "hydro";
|
||||||
|
|
||||||
|
export interface WeatherWarning {
|
||||||
|
id: string;
|
||||||
|
kind: WarningKind;
|
||||||
|
level: number | null;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
probability: number | null;
|
||||||
|
areas: string[];
|
||||||
|
office: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeatherMood = "clear" | "rain" | "wind" | "cold" | "night" | "mild";
|
||||||
Reference in New Issue
Block a user