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,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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { DashboardPage } from "@/components/dashboard/dashboard-page";
export default function HomePage() {
return <DashboardPage />;
}

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