feat: build production-ready wtr weather PWA

This commit is contained in:
zv
2026-06-01 18:43:56 +02:00
commit 840555f4f5
60 changed files with 9052 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}