feat: build production-ready wtr weather PWA
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user