feat: add Polish and English language switcher
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
|
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Next.js z App Router i TypeScript
|
- Next.js z App Router i TypeScript
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const themeScript = `
|
|||||||
document.documentElement.classList.toggle("dark", storedTheme ? storedTheme === "dark" : prefersDark);
|
document.documentElement.classList.toggle("dark", storedTheme ? storedTheme === "dark" : prefersDark);
|
||||||
} catch {}
|
} catch {}
|
||||||
`;
|
`;
|
||||||
|
const languageScript = `
|
||||||
|
try {
|
||||||
|
document.documentElement.lang = localStorage.getItem("wtr:language") === "en" ? "en" : "pl";
|
||||||
|
} catch {}
|
||||||
|
`;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { default: "wtr. | Pogoda z danych IMGW", template: "%s | wtr." },
|
title: { default: "wtr. | Pogoda z danych IMGW", template: "%s | wtr." },
|
||||||
@@ -41,6 +46,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||||||
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
|
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
|
||||||
<body className={`${inter.variable} font-sans`}>
|
<body className={`${inter.variable} font-sans`}>
|
||||||
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script>
|
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script>
|
||||||
|
<Script id="wtr-language" strategy="beforeInteractive">{languageScript}</Script>
|
||||||
<Providers>
|
<Providers>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WifiOff } from "lucide-react";
|
import { WifiOff } from "lucide-react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function OfflinePage() {
|
export default function OfflinePage() {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<section className="glass mx-auto mt-12 max-w-lg rounded-[2rem] p-8 text-center">
|
<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>
|
<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>
|
<h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</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>
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("offline.description")}</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>
|
<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">{t("offline.back")}</Link>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { WarningsPanel } from "@/components/warnings/warnings-panel";
|
import { WarningsPageContent } from "@/components/warnings/warnings-page-content";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Ostrzeżenia" };
|
export const metadata: Metadata = { title: "Ostrzeżenia / Warnings" };
|
||||||
|
|
||||||
export default function WarningsPage() {
|
export default function WarningsPage() {
|
||||||
return (
|
return <WarningsPageContent />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function SnapshotChart({ station }: { station: SynopStation }) {
|
export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const rows = [
|
const rows = [
|
||||||
{ name: "Wilgotność", value: station.humidity, unit: "%", max: 100, color: "#38bdf8" },
|
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: "#38bdf8" },
|
||||||
{ name: "Wiatr", value: station.windSpeed, unit: "m/s", max: 20, color: "#818cf8" },
|
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: "#818cf8" },
|
||||||
{ name: "Opad", value: station.rainfall, unit: "mm", max: 30, color: "#22d3ee" },
|
{ name: t("weather.rainfall"), 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) }));
|
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Snapshot pomiarowy</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("snapshot.label")}</p>
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">Aktualne proporcje parametrów</h2>
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</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>
|
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
|
||||||
<div className="mt-5 h-52 w-full">
|
<div className="mt-5 h-52 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { FavoritesSection } from "@/components/weather/favorites-section";
|
|||||||
import { StationsExplorer } from "@/components/weather/stations-explorer";
|
import { StationsExplorer } from "@/components/weather/stations-explorer";
|
||||||
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data: stations, isPending, isError, refetch } = useWeatherStations();
|
const { data: stations, isPending, isError, refetch } = useWeatherStations();
|
||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać listy stacji synoptycznych IMGW." />;
|
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
||||||
const selectedStation = stations.find((station) => station.id === selectedStationId)
|
const selectedStation = stations.find((station) => station.id === selectedStationId)
|
||||||
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||||
?? stations[0];
|
?? stations[0];
|
||||||
|
|||||||
@@ -8,37 +8,39 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
import { EmptyState } from "@/components/states/empty-state";
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
const PAGE_SIZE = 48;
|
const PAGE_SIZE = 48;
|
||||||
|
|
||||||
export function HydroPage() {
|
export function HydroPage() {
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const { data: stations, isPending, isError, refetch } = useHydroStations();
|
const { data: stations, isPending, isError, refetch } = useHydroStations();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
const filteredStations = useMemo(() => (stations ?? []).filter((station) => {
|
const filteredStations = useMemo(() => (stations ?? []).filter((station) => {
|
||||||
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase("pl");
|
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
|
||||||
return haystack.includes(query.trim().toLocaleLowerCase("pl"));
|
return haystack.includes(query.trim().toLocaleLowerCase(locale));
|
||||||
}), [query, stations]);
|
}), [locale, query, stations]);
|
||||||
|
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
if (isError) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać stacji hydrologicznych IMGW." />;
|
if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">Monitoring wód IMGW</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">{t("hydro.section")}</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Hydro</h1>
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</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>
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("hydro.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="glass relative block rounded-[1.5rem] p-3">
|
<label className="glass relative block rounded-[1.5rem] p-3">
|
||||||
<span className="sr-only">Szukaj stacji hydrologicznej</span>
|
<span className="sr-only">{t("hydro.searchLabel")}</span>
|
||||||
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
<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" />
|
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} 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>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">Znaleziono {filteredStations.length} stacji. Wyświetlono {Math.min(visibleCount, filteredStations.length)}.</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">{t("hydro.results", { total: filteredStations.length, visible: 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." /> : (
|
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
|
||||||
<>
|
<>
|
||||||
<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>
|
<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>}
|
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>{t("hydro.more")}</Button></div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,23 +5,25 @@ import { Activity, Droplets, MapPin, Thermometer } from "lucide-react";
|
|||||||
import type { HydroStation } from "@/types/imgw";
|
import type { HydroStation } from "@/types/imgw";
|
||||||
import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils";
|
import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) {
|
export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
return (
|
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 }}>
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold tracking-tight">{station.name}</h2>
|
<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>
|
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||||
<HydroMetric icon={Droplets} label="Poziom" value={formatWaterLevel(station.waterLevel)} />
|
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} />
|
||||||
<HydroMetric icon={Thermometer} label="Woda" value={formatTemperature(station.waterTemperature)} />
|
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
|
||||||
<HydroMetric icon={Activity} label="Przepływ" value={formatFlow(station.flow)} />
|
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-[0.7rem] text-slate-500 dark:text-slate-400">Pomiar poziomu: {formatDateTime(station.waterLevelMeasuredAt)}</p>
|
<p className="mt-4 text-[0.7rem] text-slate-500 dark:text-slate-400">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import { NAV_ITEMS } from "@/lib/constants";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { InstallPWAButton } from "@/components/ui/install-pwa-button";
|
import { InstallPWAButton } from "@/components/ui/install-pwa-button";
|
||||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
|
import { LanguageToggle } from "@/components/ui/language-toggle";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
const icons = [CloudSun, TriangleAlert, Droplets];
|
const icons = [CloudSun, TriangleAlert, Droplets];
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
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%)]">
|
<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">
|
<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">
|
||||||
@@ -19,31 +22,32 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
<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">
|
<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>
|
wtr<span className="text-sky-600 dark:text-sky-300">.</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label="Główna nawigacja" className="hidden items-center gap-1 md:flex">
|
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href} className={cn("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")}>
|
<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}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<InstallPWAButton />
|
<InstallPWAButton />
|
||||||
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
<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 aria-label={t("nav.mobile")} 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) => {
|
{NAV_ITEMS.map((item, index) => {
|
||||||
const Icon = icons[index];
|
const Icon = icons[index];
|
||||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-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")}>
|
<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" />
|
<Icon className="size-4" />
|
||||||
{item.label}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
import { useState, type PropsWithChildren } from "react";
|
import { useState, type PropsWithChildren } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ServiceWorkerRegister } from "@/components/layout/service-worker-register";
|
import { ServiceWorkerRegister } from "@/components/layout/service-worker-register";
|
||||||
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
|
|
||||||
export function Providers({ children }: PropsWithChildren) {
|
export function Providers({ children }: PropsWithChildren) {
|
||||||
const [queryClient] = useState(() => new QueryClient());
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<I18nProvider>
|
||||||
{children}
|
<QueryClientProvider client={queryClient}>
|
||||||
<ServiceWorkerRegister />
|
{children}
|
||||||
</QueryClientProvider>
|
<ServiceWorkerRegister />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { RefreshCw, TriangleAlert } from "lucide-react";
|
import { RefreshCw, TriangleAlert } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
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 }) {
|
export function ErrorState({ title, description, onRetry }: { title?: string; description?: string; onRetry: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
<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>
|
<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>
|
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
|
||||||
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description}</p>
|
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description ?? t("error.description")}</p>
|
||||||
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />Spróbuj ponownie</Button>
|
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function LoadingSkeleton({ className = "" }: { className?: string }) {
|
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" />;
|
const { t } = useI18n();
|
||||||
|
return <div className={cn("animate-pulse rounded-[1.75rem] bg-white/40 dark:bg-white/10", className)} aria-label={t("common.loading")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageLoadingSkeleton() {
|
export function PageLoadingSkeleton() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
@@ -11,6 +12,7 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
|
|
||||||
export function InstallPWAButton() {
|
export function InstallPWAButton() {
|
||||||
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const { t } = useI18n();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePrompt = (promptEvent: Event) => {
|
const handlePrompt = (promptEvent: Event) => {
|
||||||
promptEvent.preventDefault();
|
promptEvent.preventDefault();
|
||||||
@@ -31,7 +33,7 @@ export function InstallPWAButton() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Zainstaluj
|
{t("pwa.install")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
components/ui/language-toggle.tsx
Normal file
23
components/ui/language-toggle.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Languages } from "lucide-react";
|
||||||
|
import { useI18n, type Language } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export function LanguageToggle() {
|
||||||
|
const { language, setLanguage, t } = useI18n();
|
||||||
|
return (
|
||||||
|
<label className="relative flex items-center">
|
||||||
|
<span className="sr-only">{t("language.label")}</span>
|
||||||
|
<Languages className="pointer-events-none absolute left-3 size-4 text-slate-700 dark:text-slate-200" />
|
||||||
|
<select
|
||||||
|
aria-label={t("language.label")}
|
||||||
|
value={language}
|
||||||
|
onChange={(event) => setLanguage(event.target.value as Language)}
|
||||||
|
className="h-10 appearance-none rounded-full border border-white/30 bg-white/30 py-2 pl-9 pr-3 text-xs font-semibold uppercase text-slate-800 backdrop-blur-xl transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<option value="pl">{t("language.polish")}</option>
|
||||||
|
<option value="en">{t("language.english")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Moon, Sun, SunMoon } from "lucide-react";
|
import { Moon, Sun, SunMoon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -39,7 +41,7 @@ export function ThemeToggle() {
|
|||||||
<Button
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={!mounted ? "Zmień motyw" : isDark ? "Włącz jasny motyw" : "Włącz ciemny motyw"}
|
aria-label={!mounted ? t("theme.change") : isDark ? t("theme.light") : t("theme.dark")}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
>
|
>
|
||||||
{!mounted ? <SunMoon className="size-4" /> : isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
{!mounted ? <SunMoon className="size-4" /> : isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import type { WeatherWarning } from "@/types/imgw";
|
|||||||
import { formatDateTime } from "@/lib/weather-utils";
|
import { formatDateTime } from "@/lib/weather-utils";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; index?: number }) {
|
export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; index?: number }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
|
const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
|
||||||
const level = warning.level;
|
const level = warning.level;
|
||||||
const levelLabel = level === -1 ? "Susza hydrologiczna" : level === null ? "Poziom nieokreślony" : `Stopień ${level}`;
|
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
|
||||||
const areasLabel = warning.areas.length > 8
|
const areasLabel = warning.areas.length > 8
|
||||||
? `${warning.areas.slice(0, 8).join(", ")} i ${warning.areas.length - 8} więcej`
|
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
|
||||||
: warning.areas.join("; ");
|
: warning.areas.join("; ");
|
||||||
return (
|
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 }}>
|
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
|
||||||
@@ -21,14 +23,14 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
|
|||||||
<div className="rounded-2xl bg-amber-500/15 p-2.5 text-amber-700 dark:text-amber-300"><Icon className="size-5" /></div>
|
<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>
|
<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>
|
</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>
|
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500 dark:text-slate-400">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
||||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title}</h2>
|
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
|
||||||
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-slate-600 dark:text-slate-300">{warning.description}</p>}
|
{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">
|
<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"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} — {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
|
||||||
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || "Obszar nieokreślony"}</p>
|
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
|
||||||
</div>
|
</div>
|
||||||
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-slate-600 dark:text-slate-300">Prawdopodobieństwo: {warning.probability}%</p>}
|
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-slate-600 dark:text-slate-300">{t("warnings.probability", { value: warning.probability })}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
);
|
);
|
||||||
|
|||||||
18
components/warnings/warnings-page-content.tsx
Normal file
18
components/warnings/warnings-page-content.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { WarningsPanel } from "@/components/warnings/warnings-panel";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export function WarningsPageContent() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("warnings.section")}</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.description")}</p>
|
||||||
|
</div>
|
||||||
|
<WarningsPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ import { WarningCard } from "@/components/warnings/warning-card";
|
|||||||
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
import { EmptyState } from "@/components/states/empty-state";
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function WarningsPanel() {
|
export function WarningsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
if (isError) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych." />;
|
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
||||||
if (!warnings?.length) return <EmptyState title="Brak aktywnych ostrzeżeń" description="IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych." />;
|
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
||||||
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>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react";
|
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react";
|
||||||
import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, formatTemperature, formatWind, getWindDirection } from "@/lib/weather-utils";
|
import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, formatTemperature, formatWind, getWindDirection } from "@/lib/weather-utils";
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
import { MetricCard } from "@/components/weather/metric-card";
|
import { MetricCard } from "@/components/weather/metric-card";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function CurrentConditionsCard({ station }: { station: SynopStation }) {
|
export function CurrentConditionsCard({ station }: { station: SynopStation }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ icon: Thermometer, label: "Odczuwalna", value: formatTemperature(feelsLike), detail: "Wartość obliczana, gdy warunki na to pozwalają" },
|
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language), detail: t("weather.feelsLikeDetail") },
|
||||||
{ icon: Droplets, label: "Wilgotność", value: formatHumidity(station.humidity), detail: "Wilgotność względna powietrza" },
|
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") },
|
||||||
{ icon: Gauge, label: "Ciśnienie", value: formatPressure(station.pressure), detail: "Ciśnienie atmosferyczne" },
|
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") },
|
||||||
{ icon: Wind, label: "Prędkość wiatru", value: formatWind(station.windSpeed), detail: "Bieżący odczyt IMGW" },
|
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language), detail: t("weather.windSpeedDetail") },
|
||||||
{ icon: Navigation, label: "Kierunek wiatru", value: station.windDirection === null ? "Brak danych" : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: "Kierunek napływu wiatru" },
|
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") },
|
||||||
{ icon: Umbrella, label: "Suma opadu", value: formatRainfall(station.rainfall), detail: "Suma opadu z pomiaru IMGW" },
|
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") },
|
||||||
{ icon: CloudSun, label: "Temperatura", value: formatTemperature(station.temperature), detail: "Temperatura powietrza" },
|
{ icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language), detail: t("weather.temperatureDetail") },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import { Heart } from "lucide-react";
|
|||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
import { StationCard } from "@/components/weather/station-card";
|
import { StationCard } from "@/components/weather/station-card";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
|
export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const favoriteIds = useWeatherStore((state) => state.favorites);
|
const favoriteIds = useWeatherStore((state) => state.favorites);
|
||||||
const favorites = stations.filter((station) => favoriteIds.includes(station.id));
|
const favorites = stations.filter((station) => favoriteIds.includes(station.id));
|
||||||
if (!favorites.length) return (
|
if (!favorites.length) return (
|
||||||
<section className="glass-subtle rounded-[1.75rem] p-5">
|
<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>
|
<div className="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 text-rose-500" />{t("favorites.title")}</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>
|
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{t("favorites.empty")}</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<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="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 fill-rose-500 text-rose-500" />{t("favorites.title")}</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>
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { WeatherIcon } from "@/components/weather/weather-icon";
|
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function StationCard({ station, index = 0 }: { station: SynopStation; index?: number }) {
|
export function StationCard({ station, index = 0 }: { station: SynopStation; index?: number }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
const favorites = useWeatherStore((state) => state.favorites);
|
const favorites = useWeatherStore((state) => state.favorites);
|
||||||
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
||||||
const selectStation = useWeatherStore((state) => state.selectStation);
|
const selectStation = useWeatherStore((state) => state.selectStation);
|
||||||
@@ -24,19 +26,19 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<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">
|
<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="truncate text-sm font-semibold">{station.name}</p>
|
||||||
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature)}</p>
|
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<WeatherIcon mood={mood} className="size-9 text-sky-600 dark:text-sky-300" />
|
<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)}>
|
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}>
|
||||||
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span>
|
||||||
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</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>
|
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -13,40 +13,42 @@ import { WeatherDetailsGrid } from "@/components/weather/current-conditions-card
|
|||||||
import { SnapshotChart } from "@/components/charts/snapshot-chart";
|
import { SnapshotChart } from "@/components/charts/snapshot-chart";
|
||||||
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function StationDetailPage({ id }: { id: string }) {
|
export function StationDetailPage({ id }: { id: string }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
const { data: station, isPending, isError, refetch } = useWeatherStation(id);
|
const { data: station, isPending, isError, refetch } = useWeatherStation(id);
|
||||||
const favoriteIds = useWeatherStore((state) => state.favorites);
|
const favoriteIds = useWeatherStore((state) => state.favorites);
|
||||||
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
||||||
if (isPending) return <PageLoadingSkeleton />;
|
if (isPending) return <PageLoadingSkeleton />;
|
||||||
if (isError || !station) return <ErrorState onRetry={() => refetch()} description="Nie udało się pobrać danych wybranej stacji IMGW." />;
|
if (isError || !station) return <ErrorState onRetry={() => refetch()} description={t("station.error")} />;
|
||||||
const favorite = favoriteIds.includes(station.id);
|
const favorite = favoriteIds.includes(station.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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>
|
<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" />{t("station.all")}</Link>
|
||||||
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
|
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
|
||||||
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||||
{favorite ? "Usuń z ulubionych" : "Dodaj do ulubionych"}
|
{favorite ? t("favorites.remove") : t("favorites.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<WeatherHero station={station} />
|
<WeatherHero station={station} />
|
||||||
<section>
|
<section>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Stacja {station.name}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("station.label", { name: station.name })}</p>
|
||||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Aktualne parametry</h1>
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{t("station.parameters")}</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>
|
<p className="mb-4 mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.parametersDescription")}</p>
|
||||||
<WeatherDetailsGrid station={station} />
|
<WeatherDetailsGrid station={station} />
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
|
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
<SnapshotChart station={station} />
|
<SnapshotChart station={station} />
|
||||||
<Card className="p-5">
|
<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>
|
<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]">{t("station.quality")}</p></div>
|
||||||
<h2 className="mt-4 text-xl font-semibold tracking-tight">Ostatni pomiar IMGW</h2>
|
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</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>
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.qualityDescription")}</p>
|
||||||
<dl className="mt-6 space-y-3 text-sm">
|
<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">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</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>
|
<div><dt className="text-slate-500 dark:text-slate-400">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
import { StationCard } from "@/components/weather/station-card";
|
import { StationCard } from "@/components/weather/station-card";
|
||||||
import { EmptyState } from "@/components/states/empty-state";
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
import { SearchX } from "lucide-react";
|
import { SearchX } from "lucide-react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function StationGrid({ stations }: { stations: SynopStation[] }) {
|
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." />;
|
const { t } = useI18n();
|
||||||
|
if (!stations.length) return <EmptyState icon={SearchX} title={t("stations.emptyTitle")} description={t("stations.emptyDescription")} />;
|
||||||
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>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Search, SlidersHorizontal } from "lucide-react";
|
import { Search, SlidersHorizontal } from "lucide-react";
|
||||||
import type { StationFilter, StationSort } from "@/components/weather/stations-explorer";
|
import type { StationFilter, StationSort } from "@/components/weather/stations-explorer";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
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 }) {
|
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 }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="glass grid gap-3 rounded-[1.75rem] p-3 sm:grid-cols-[1fr_auto_auto]">
|
<div className="glass grid gap-3 rounded-[1.75rem] p-3 sm:grid-cols-[1fr_auto_auto]">
|
||||||
<label className="relative">
|
<label className="relative">
|
||||||
<span className="sr-only">Szukaj stacji synoptycznej</span>
|
<span className="sr-only">{t("stations.searchLabel")}</span>
|
||||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
<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" />
|
<input value={query} onChange={(event) => onQueryChange(event.target.value)} placeholder={t("stations.searchPlaceholder")} 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>
|
||||||
<label className="relative">
|
<label className="relative">
|
||||||
<span className="sr-only">Sortowanie stacji</span>
|
<span className="sr-only">{t("stations.sortLabel")}</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">
|
<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="alphabetical">{t("stations.sortAlphabetical")}</option>
|
||||||
<option value="temperature-desc">Temperatura: najwyższa</option>
|
<option value="temperature-desc">{t("stations.sortTemperatureDesc")}</option>
|
||||||
<option value="temperature-asc">Temperatura: najniższa</option>
|
<option value="temperature-asc">{t("stations.sortTemperatureAsc")}</option>
|
||||||
<option value="humidity-desc">Wilgotność: najwyższa</option>
|
<option value="humidity-desc">{t("stations.sortHumidityDesc")}</option>
|
||||||
<option value="pressure-desc">Ciśnienie: najwyższe</option>
|
<option value="pressure-desc">{t("stations.sortPressureDesc")}</option>
|
||||||
</select>
|
</select>
|
||||||
<SlidersHorizontal className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
<SlidersHorizontal className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className="sr-only">Filtr stacji</span>
|
<span className="sr-only">{t("stations.filterLabel")}</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">
|
<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="all">{t("stations.filterAll")}</option>
|
||||||
<option value="warmest">Najcieplejsze</option>
|
<option value="warmest">{t("stations.filterWarmest")}</option>
|
||||||
<option value="coldest">Najzimniejsze</option>
|
<option value="coldest">{t("stations.filterColdest")}</option>
|
||||||
<option value="windy">Największy wiatr</option>
|
<option value="windy">{t("stations.filterWindy")}</option>
|
||||||
<option value="rainy">Największy opad</option>
|
<option value="rainy">{t("stations.filterRainy")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
|||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
import { StationGrid } from "@/components/weather/station-grid";
|
import { StationGrid } from "@/components/weather/station-grid";
|
||||||
import { StationSearch } from "@/components/weather/station-search";
|
import { StationSearch } from "@/components/weather/station-search";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export type StationSort = "alphabetical" | "temperature-desc" | "temperature-asc" | "humidity-desc" | "pressure-desc";
|
export type StationSort = "alphabetical" | "temperature-desc" | "temperature-asc" | "humidity-desc" | "pressure-desc";
|
||||||
export type StationFilter = "all" | "warmest" | "coldest" | "windy" | "rainy";
|
export type StationFilter = "all" | "warmest" | "coldest" | "windy" | "rainy";
|
||||||
@@ -15,28 +16,29 @@ function compareNumbers(a: number | null, b: number | null, direction: "asc" | "
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StationsExplorer({ stations }: { stations: SynopStation[] }) {
|
export function StationsExplorer({ stations }: { stations: SynopStation[] }) {
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<StationSort>("alphabetical");
|
const [sort, setSort] = useState<StationSort>("alphabetical");
|
||||||
const [filter, setFilter] = useState<StationFilter>("all");
|
const [filter, setFilter] = useState<StationFilter>("all");
|
||||||
const visibleStations = useMemo(() => {
|
const visibleStations = useMemo(() => {
|
||||||
const searched = stations.filter((station) => station.name.toLocaleLowerCase("pl").includes(query.trim().toLocaleLowerCase("pl")));
|
const searched = stations.filter((station) => station.name.toLocaleLowerCase(locale).includes(query.trim().toLocaleLowerCase(locale)));
|
||||||
const sorted = [...searched].sort((a, b) => {
|
const sorted = [...searched].sort((a, b) => {
|
||||||
if (sort === "temperature-desc") return compareNumbers(a.temperature, b.temperature, "desc");
|
if (sort === "temperature-desc") return compareNumbers(a.temperature, b.temperature, "desc");
|
||||||
if (sort === "temperature-asc") return compareNumbers(a.temperature, b.temperature, "asc");
|
if (sort === "temperature-asc") return compareNumbers(a.temperature, b.temperature, "asc");
|
||||||
if (sort === "humidity-desc") return compareNumbers(a.humidity, b.humidity, "desc");
|
if (sort === "humidity-desc") return compareNumbers(a.humidity, b.humidity, "desc");
|
||||||
if (sort === "pressure-desc") return compareNumbers(a.pressure, b.pressure, "desc");
|
if (sort === "pressure-desc") return compareNumbers(a.pressure, b.pressure, "desc");
|
||||||
return a.name.localeCompare(b.name, "pl");
|
return a.name.localeCompare(b.name, locale);
|
||||||
});
|
});
|
||||||
if (filter === "all") return sorted;
|
if (filter === "all") return sorted;
|
||||||
const key = { warmest: "temperature", coldest: "temperature", windy: "windSpeed", rainy: "rainfall" }[filter] as keyof SynopStation;
|
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);
|
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]);
|
}, [filter, locale, query, sort, stations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">Stacje synoptyczne</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("stations.section")}</p>
|
||||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Pogoda w Polsce</h2>
|
<h2 className="mt-2 text-2xl font-semibold tracking-tight">{t("stations.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<StationSearch query={query} onQueryChange={setQuery} sort={sort} onSortChange={setSort} filter={filter} onFilterChange={setFilter} />
|
<StationSearch query={query} onQueryChange={setQuery} sort={sort} onSortChange={setSort} filter={filter} onFilterChange={setFilter} />
|
||||||
<StationGrid stations={visibleStations} />
|
<StationGrid stations={visibleStations} />
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ import {
|
|||||||
} from "@/lib/weather-utils";
|
} from "@/lib/weather-utils";
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
import { WeatherIcon } from "@/components/weather/weather-icon";
|
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function WeatherHero({ station }: { station: SynopStation }) {
|
export function WeatherHero({ station }: { station: SynopStation }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
const mood = getWeatherMoodFromData(station);
|
const mood = getWeatherMoodFromData(station);
|
||||||
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ icon: Droplets, label: "Wilgotność", value: formatHumidity(station.humidity) },
|
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language) },
|
||||||
{ icon: Wind, label: "Wiatr", value: formatWind(station.windSpeed) },
|
{ icon: Wind, label: t("weather.wind"), value: formatWind(station.windSpeed, null, language) },
|
||||||
{ icon: Umbrella, label: "Opad", value: formatRainfall(station.rainfall) },
|
{ icon: Umbrella, label: t("weather.rainfall"), value: formatRainfall(station.rainfall, language) },
|
||||||
{ icon: Gauge, label: "Ciśnienie", value: formatPressure(station.pressure) },
|
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,10 +45,10 @@ export function WeatherHero({ station }: { station: SynopStation }) {
|
|||||||
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[5.8rem] font-extralight leading-[0.85] tracking-[-0.11em] sm:text-[8rem]">
|
<div className="text-[5.8rem] font-extralight leading-[0.85] tracking-[-0.11em] sm:text-[8rem]">
|
||||||
{formatTemperature(station.temperature)}
|
{formatTemperature(station.temperature, language)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(station)}</p>
|
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(station, language)}</p>
|
||||||
<p className="mt-1 text-sm text-white/75">Odczuwalna {formatTemperature(feelsLike)} · pomiar {formatDateTime(station.measuredAt)}</p>
|
<p className="mt-1 text-sm text-white/75">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(station.measuredAt, language)}</p>
|
||||||
</div>
|
</div>
|
||||||
<WeatherIcon mood={mood} className="mb-4 size-20 text-white/80 sm:size-28" />
|
<WeatherIcon mood={mood} className="mb-4 size-20 text-white/80 sm:size-28" />
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +63,7 @@ export function WeatherHero({ station }: { station: SynopStation }) {
|
|||||||
{station.windDirection !== null && (
|
{station.windDirection !== null && (
|
||||||
<p className="mt-4 flex items-center gap-1.5 text-xs text-white/70">
|
<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)` }} />
|
<Navigation className="size-3.5" style={{ transform: `rotate(${station.windDirection}deg)` }} />
|
||||||
Kierunek wiatru: {station.windDirection}°
|
{t("weather.windDirection")}: {station.windDirection}°
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const QUERY_STALE_TIME = 5 * 60 * 1000;
|
|||||||
export const QUERY_GC_TIME = 60 * 60 * 1000;
|
export const QUERY_GC_TIME = 60 * 60 * 1000;
|
||||||
|
|
||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{ href: "/", label: "Pogoda" },
|
{ href: "/", labelKey: "nav.weather" },
|
||||||
{ href: "/warnings", label: "Ostrzeżenia" },
|
{ href: "/warnings", labelKey: "nav.warnings" },
|
||||||
{ href: "/hydro", label: "Hydro" },
|
{ href: "/hydro", labelKey: "nav.hydro" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
290
lib/i18n.tsx
Normal file
290
lib/i18n.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export type Language = "pl" | "en";
|
||||||
|
type TranslationParams = Record<string, string | number>;
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
pl: {
|
||||||
|
"nav.weather": "Pogoda",
|
||||||
|
"nav.warnings": "Ostrzeżenia",
|
||||||
|
"nav.hydro": "Hydro",
|
||||||
|
"nav.main": "Główna nawigacja",
|
||||||
|
"nav.mobile": "Mobilna nawigacja",
|
||||||
|
"language.label": "Wybierz język",
|
||||||
|
"language.polish": "Polski",
|
||||||
|
"language.english": "English",
|
||||||
|
"theme.change": "Zmień motyw",
|
||||||
|
"theme.light": "Włącz jasny motyw",
|
||||||
|
"theme.dark": "Włącz ciemny motyw",
|
||||||
|
"pwa.install": "Zainstaluj",
|
||||||
|
"common.noData": "Brak danych",
|
||||||
|
"common.loading": "Ładowanie danych",
|
||||||
|
"common.retry": "Spróbuj ponownie",
|
||||||
|
"error.title": "Nie udało się pobrać danych",
|
||||||
|
"error.description": "Sprawdź połączenie i spróbuj ponownie.",
|
||||||
|
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
|
||||||
|
"favorites.title": "Ulubione lokalizacje",
|
||||||
|
"favorites.empty": "Dodaj stacje do ulubionych, aby mieć ich odczyty pod ręką.",
|
||||||
|
"favorites.addStation": "Dodaj {name} do ulubionych",
|
||||||
|
"favorites.removeStation": "Usuń {name} z ulubionych",
|
||||||
|
"favorites.add": "Dodaj do ulubionych",
|
||||||
|
"favorites.remove": "Usuń z ulubionych",
|
||||||
|
"weather.humidity": "Wilgotność",
|
||||||
|
"weather.wind": "Wiatr",
|
||||||
|
"weather.rainfall": "Opad",
|
||||||
|
"weather.pressure": "Ciśnienie",
|
||||||
|
"weather.feelsLike": "Odczuwalna",
|
||||||
|
"weather.measurement": "pomiar",
|
||||||
|
"weather.windDirection": "Kierunek wiatru",
|
||||||
|
"weather.calm": "Spokojne warunki",
|
||||||
|
"weather.humid": "Wilgotno",
|
||||||
|
"weather.strongWind": "Silny wiatr",
|
||||||
|
"weather.rain": "Opady",
|
||||||
|
"weather.heavyRain": "Wyraźne opady",
|
||||||
|
"weather.airTemperature": "Temperatura",
|
||||||
|
"weather.windSpeed": "Prędkość wiatru",
|
||||||
|
"weather.rainfallTotal": "Suma opadu",
|
||||||
|
"weather.feelsLikeDetail": "Wartość obliczana, gdy warunki na to pozwalają",
|
||||||
|
"weather.humidityDetail": "Wilgotność względna powietrza",
|
||||||
|
"weather.pressureDetail": "Ciśnienie atmosferyczne",
|
||||||
|
"weather.windSpeedDetail": "Bieżący odczyt IMGW",
|
||||||
|
"weather.windDirectionDetail": "Kierunek napływu wiatru",
|
||||||
|
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
|
||||||
|
"weather.temperatureDetail": "Temperatura powietrza",
|
||||||
|
"stations.section": "Stacje synoptyczne",
|
||||||
|
"stations.title": "Pogoda w Polsce",
|
||||||
|
"stations.searchLabel": "Szukaj stacji synoptycznej",
|
||||||
|
"stations.searchPlaceholder": "Szukaj stacji IMGW…",
|
||||||
|
"stations.sortLabel": "Sortowanie stacji",
|
||||||
|
"stations.filterLabel": "Filtr stacji",
|
||||||
|
"stations.sortAlphabetical": "Alfabetycznie",
|
||||||
|
"stations.sortTemperatureDesc": "Temperatura: najwyższa",
|
||||||
|
"stations.sortTemperatureAsc": "Temperatura: najniższa",
|
||||||
|
"stations.sortHumidityDesc": "Wilgotność: najwyższa",
|
||||||
|
"stations.sortPressureDesc": "Ciśnienie: najwyższe",
|
||||||
|
"stations.filterAll": "Wszystkie stacje",
|
||||||
|
"stations.filterWarmest": "Najcieplejsze",
|
||||||
|
"stations.filterColdest": "Najzimniejsze",
|
||||||
|
"stations.filterWindy": "Największy wiatr",
|
||||||
|
"stations.filterRainy": "Największy opad",
|
||||||
|
"stations.emptyTitle": "Brak pasujących stacji",
|
||||||
|
"stations.emptyDescription": "Zmień wyszukiwanie lub wybierz inny filtr.",
|
||||||
|
"station.all": "Wszystkie stacje",
|
||||||
|
"station.label": "Stacja {name}",
|
||||||
|
"station.parameters": "Aktualne parametry",
|
||||||
|
"station.parametersDescription": "Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.",
|
||||||
|
"station.error": "Nie udało się pobrać danych wybranej stacji IMGW.",
|
||||||
|
"station.quality": "Jakość danych",
|
||||||
|
"station.lastMeasurementImgw": "Ostatni pomiar IMGW",
|
||||||
|
"station.qualityDescription": "Czas poniżej pochodzi bezpośrednio z najnowszego odczytu udostępnionego przez IMGW.",
|
||||||
|
"station.lastMeasurement": "Ostatni pomiar",
|
||||||
|
"station.source": "Źródło",
|
||||||
|
"station.publicApi": "Publiczne API IMGW",
|
||||||
|
"snapshot.label": "Snapshot pomiarowy",
|
||||||
|
"snapshot.title": "Aktualne proporcje parametrów",
|
||||||
|
"snapshot.description": "Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
|
||||||
|
"warnings.section": "Komunikaty IMGW",
|
||||||
|
"warnings.title": "Ostrzeżenia",
|
||||||
|
"warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
|
||||||
|
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
|
||||||
|
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
|
||||||
|
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
|
||||||
|
"warnings.drought": "Susza hydrologiczna",
|
||||||
|
"warnings.levelUnknown": "Poziom nieokreślony",
|
||||||
|
"warnings.level": "Stopień {level}",
|
||||||
|
"warnings.hydro": "Hydrologiczne",
|
||||||
|
"warnings.meteo": "Meteorologiczne",
|
||||||
|
"warnings.untilCancelled": "do odwołania",
|
||||||
|
"warnings.areaUnknown": "Obszar nieokreślony",
|
||||||
|
"warnings.moreAreas": "i {count} więcej",
|
||||||
|
"warnings.probability": "Prawdopodobieństwo: {value}%",
|
||||||
|
"warnings.genericHydro": "Ostrzeżenie hydrologiczne",
|
||||||
|
"warnings.genericMeteo": "Ostrzeżenie meteorologiczne",
|
||||||
|
"hydro.section": "Monitoring wód IMGW",
|
||||||
|
"hydro.title": "Hydro",
|
||||||
|
"hydro.description": "Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
|
||||||
|
"hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.",
|
||||||
|
"hydro.searchLabel": "Szukaj stacji hydrologicznej",
|
||||||
|
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
|
||||||
|
"hydro.results": "Znaleziono {total} stacji. Wyświetlono {visible}.",
|
||||||
|
"hydro.emptyDescription": "Zmień wyszukiwaną nazwę stacji, rzeki lub województwa.",
|
||||||
|
"hydro.more": "Pokaż więcej stacji",
|
||||||
|
"hydro.riverUnavailable": "Rzeka: brak danych",
|
||||||
|
"hydro.level": "Poziom",
|
||||||
|
"hydro.water": "Woda",
|
||||||
|
"hydro.flow": "Przepływ",
|
||||||
|
"hydro.levelMeasurement": "Pomiar poziomu: {date}",
|
||||||
|
"offline.title": "Brak połączenia",
|
||||||
|
"offline.description": "wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
|
||||||
|
"offline.back": "Wróć do aplikacji",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
"nav.weather": "Weather",
|
||||||
|
"nav.warnings": "Warnings",
|
||||||
|
"nav.hydro": "Hydro",
|
||||||
|
"nav.main": "Main navigation",
|
||||||
|
"nav.mobile": "Mobile navigation",
|
||||||
|
"language.label": "Select language",
|
||||||
|
"language.polish": "Polski",
|
||||||
|
"language.english": "English",
|
||||||
|
"theme.change": "Change theme",
|
||||||
|
"theme.light": "Enable light theme",
|
||||||
|
"theme.dark": "Enable dark theme",
|
||||||
|
"pwa.install": "Install",
|
||||||
|
"common.noData": "No data",
|
||||||
|
"common.loading": "Loading data",
|
||||||
|
"common.retry": "Try again",
|
||||||
|
"error.title": "Unable to load data",
|
||||||
|
"error.description": "Check your connection and try again.",
|
||||||
|
"dashboard.error": "Unable to load the IMGW synoptic station list.",
|
||||||
|
"favorites.title": "Favourite locations",
|
||||||
|
"favorites.empty": "Add stations to favourites to keep their readings close at hand.",
|
||||||
|
"favorites.addStation": "Add {name} to favourites",
|
||||||
|
"favorites.removeStation": "Remove {name} from favourites",
|
||||||
|
"favorites.add": "Add to favourites",
|
||||||
|
"favorites.remove": "Remove from favourites",
|
||||||
|
"weather.humidity": "Humidity",
|
||||||
|
"weather.wind": "Wind",
|
||||||
|
"weather.rainfall": "Rainfall",
|
||||||
|
"weather.pressure": "Pressure",
|
||||||
|
"weather.feelsLike": "Feels like",
|
||||||
|
"weather.measurement": "measurement",
|
||||||
|
"weather.windDirection": "Wind direction",
|
||||||
|
"weather.calm": "Calm conditions",
|
||||||
|
"weather.humid": "Humid",
|
||||||
|
"weather.strongWind": "Strong wind",
|
||||||
|
"weather.rain": "Rainfall",
|
||||||
|
"weather.heavyRain": "Heavy rainfall",
|
||||||
|
"weather.airTemperature": "Temperature",
|
||||||
|
"weather.windSpeed": "Wind speed",
|
||||||
|
"weather.rainfallTotal": "Rainfall total",
|
||||||
|
"weather.feelsLikeDetail": "Calculated when the available conditions allow it",
|
||||||
|
"weather.humidityDetail": "Relative air humidity",
|
||||||
|
"weather.pressureDetail": "Atmospheric pressure",
|
||||||
|
"weather.windSpeedDetail": "Current IMGW reading",
|
||||||
|
"weather.windDirectionDetail": "Direction the wind is coming from",
|
||||||
|
"weather.rainfallDetail": "Total rainfall from the IMGW reading",
|
||||||
|
"weather.temperatureDetail": "Air temperature",
|
||||||
|
"stations.section": "Synoptic stations",
|
||||||
|
"stations.title": "Weather in Poland",
|
||||||
|
"stations.searchLabel": "Search synoptic stations",
|
||||||
|
"stations.searchPlaceholder": "Search IMGW stations…",
|
||||||
|
"stations.sortLabel": "Sort stations",
|
||||||
|
"stations.filterLabel": "Filter stations",
|
||||||
|
"stations.sortAlphabetical": "Alphabetically",
|
||||||
|
"stations.sortTemperatureDesc": "Temperature: highest",
|
||||||
|
"stations.sortTemperatureAsc": "Temperature: lowest",
|
||||||
|
"stations.sortHumidityDesc": "Humidity: highest",
|
||||||
|
"stations.sortPressureDesc": "Pressure: highest",
|
||||||
|
"stations.filterAll": "All stations",
|
||||||
|
"stations.filterWarmest": "Warmest",
|
||||||
|
"stations.filterColdest": "Coldest",
|
||||||
|
"stations.filterWindy": "Strongest wind",
|
||||||
|
"stations.filterRainy": "Highest rainfall",
|
||||||
|
"stations.emptyTitle": "No matching stations",
|
||||||
|
"stations.emptyDescription": "Adjust your search or select a different filter.",
|
||||||
|
"station.all": "All stations",
|
||||||
|
"station.label": "Station {name}",
|
||||||
|
"station.parameters": "Current parameters",
|
||||||
|
"station.parametersDescription": "The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.",
|
||||||
|
"station.error": "Unable to load data for the selected IMGW station.",
|
||||||
|
"station.quality": "Data details",
|
||||||
|
"station.lastMeasurementImgw": "Latest IMGW measurement",
|
||||||
|
"station.qualityDescription": "The time below comes directly from the latest reading published by IMGW.",
|
||||||
|
"station.lastMeasurement": "Latest measurement",
|
||||||
|
"station.source": "Source",
|
||||||
|
"station.publicApi": "Public IMGW API",
|
||||||
|
"snapshot.label": "Measurement snapshot",
|
||||||
|
"snapshot.title": "Current parameter proportions",
|
||||||
|
"snapshot.description": "Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
|
||||||
|
"warnings.section": "IMGW notices",
|
||||||
|
"warnings.title": "Warnings",
|
||||||
|
"warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
|
||||||
|
"warnings.error": "Unable to load meteorological or hydrological warnings.",
|
||||||
|
"warnings.emptyTitle": "No active warnings",
|
||||||
|
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
|
||||||
|
"warnings.drought": "Hydrological drought",
|
||||||
|
"warnings.levelUnknown": "Level not specified",
|
||||||
|
"warnings.level": "Level {level}",
|
||||||
|
"warnings.hydro": "Hydrological",
|
||||||
|
"warnings.meteo": "Meteorological",
|
||||||
|
"warnings.untilCancelled": "until cancelled",
|
||||||
|
"warnings.areaUnknown": "Area not specified",
|
||||||
|
"warnings.moreAreas": "and {count} more",
|
||||||
|
"warnings.probability": "Probability: {value}%",
|
||||||
|
"warnings.genericHydro": "Hydrological warning",
|
||||||
|
"warnings.genericMeteo": "Meteorological warning",
|
||||||
|
"hydro.section": "IMGW water monitoring",
|
||||||
|
"hydro.title": "Hydro",
|
||||||
|
"hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
|
||||||
|
"hydro.error": "Unable to load IMGW hydrological stations.",
|
||||||
|
"hydro.searchLabel": "Search hydrological stations",
|
||||||
|
"hydro.searchPlaceholder": "Search by station, river or province…",
|
||||||
|
"hydro.results": "Found {total} stations. Showing {visible}.",
|
||||||
|
"hydro.emptyDescription": "Adjust the station, river or province name in your search.",
|
||||||
|
"hydro.more": "Show more stations",
|
||||||
|
"hydro.riverUnavailable": "River: no data",
|
||||||
|
"hydro.level": "Level",
|
||||||
|
"hydro.water": "Water",
|
||||||
|
"hydro.flow": "Flow",
|
||||||
|
"hydro.levelMeasurement": "Level measurement: {date}",
|
||||||
|
"offline.title": "No connection",
|
||||||
|
"offline.description": "wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
|
||||||
|
"offline.back": "Back to the app",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof translations.pl;
|
||||||
|
|
||||||
|
function interpolate(value: string, params?: TranslationParams) {
|
||||||
|
if (!params) return value;
|
||||||
|
return value.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translate(language: Language, key: TranslationKey, params?: TranslationParams) {
|
||||||
|
return interpolate(translations[language][key] ?? translations.en[key], params);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
language: Language;
|
||||||
|
locale: string;
|
||||||
|
setLanguage: (language: Language) => void;
|
||||||
|
t: (key: TranslationKey, params?: TranslationParams) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: PropsWithChildren) {
|
||||||
|
const [language, setLanguageState] = useState<Language>("pl");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem("wtr:language");
|
||||||
|
const nextLanguage: Language = stored === "en" ? "en" : "pl";
|
||||||
|
const animationFrame = window.requestAnimationFrame(() => setLanguageState(nextLanguage));
|
||||||
|
document.documentElement.lang = nextLanguage;
|
||||||
|
return () => window.cancelAnimationFrame(animationFrame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLanguage = useCallback((nextLanguage: Language) => {
|
||||||
|
window.localStorage.setItem("wtr:language", nextLanguage);
|
||||||
|
document.documentElement.lang = nextLanguage;
|
||||||
|
setLanguageState(nextLanguage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(() => ({
|
||||||
|
language,
|
||||||
|
locale: language === "pl" ? "pl-PL" : "en-GB",
|
||||||
|
setLanguage,
|
||||||
|
t: (key, params) => translate(language, key, params),
|
||||||
|
}), [language, setLanguage]);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
if (!context) throw new Error("useI18n must be used within I18nProvider.");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -8,8 +8,9 @@ import type {
|
|||||||
WeatherWarning,
|
WeatherWarning,
|
||||||
WarningKind,
|
WarningKind,
|
||||||
} from "@/types/imgw";
|
} from "@/types/imgw";
|
||||||
|
import { translate, type Language } from "@/lib/i18n";
|
||||||
|
|
||||||
const polishLocale = "pl-PL";
|
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
|
||||||
|
|
||||||
export function toNumber(value: unknown): number | null {
|
export function toNumber(value: unknown): number | null {
|
||||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||||
@@ -70,7 +71,7 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
|
|||||||
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
||||||
.filter((area): area is string => Boolean(area));
|
.filter((area): area is string => Boolean(area));
|
||||||
const areas = describedAreas.length ? describedAreas : (raw.teryt ?? []).map((code) => `TERYT ${code}`);
|
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");
|
const title = raw.zdarzenie?.trim() || raw.nazwa_zdarzenia?.trim() || "";
|
||||||
return {
|
return {
|
||||||
id: `${kind}-${raw.id ?? raw.numer ?? index}-${raw.data_od ?? raw.obowiazuje_od ?? "unknown"}`,
|
id: `${kind}-${raw.id ?? raw.numer ?? index}-${raw.data_od ?? raw.obowiazuje_od ?? "unknown"}`,
|
||||||
kind,
|
kind,
|
||||||
@@ -87,41 +88,41 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTemperature(value: number | null) {
|
export function formatTemperature(value: number | null, language: Language = "pl") {
|
||||||
return value === null ? "Brak danych" : `${Math.round(value)}°`;
|
return value === null ? translate(language, "common.noData") : `${Math.round(value)}°`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPressure(value: number | null) {
|
export function formatPressure(value: number | null, language: Language = "pl") {
|
||||||
return value === null ? "Brak danych" : `${value.toFixed(1)} hPa`;
|
return value === null ? translate(language, "common.noData") : `${value.toFixed(1)} hPa`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHumidity(value: number | null) {
|
export function formatHumidity(value: number | null, language: Language = "pl") {
|
||||||
return value === null ? "Brak danych" : `${Math.round(value)}%`;
|
return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatWind(speed: number | null, direction?: number | null) {
|
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl") {
|
||||||
if (speed === null) return "Brak danych";
|
if (speed === null) return translate(language, "common.noData");
|
||||||
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
||||||
return `${speed.toFixed(1)} m/s${directionLabel}`;
|
return `${speed.toFixed(1)} m/s${directionLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRainfall(value: number | null) {
|
export function formatRainfall(value: number | null, language: Language = "pl") {
|
||||||
return value === null ? "Brak danych" : `${value.toFixed(value < 1 ? 2 : 1)} mm`;
|
return value === null ? translate(language, "common.noData") : `${value.toFixed(value < 1 ? 2 : 1)} mm`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatWaterLevel(value: number | null) {
|
export function formatWaterLevel(value: number | null, language: Language = "pl") {
|
||||||
return value === null ? "Brak danych" : `${Math.round(value)} cm`;
|
return value === null ? translate(language, "common.noData") : `${Math.round(value)} cm`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFlow(value: number | null) {
|
export function formatFlow(value: number | null, language: Language = "pl") {
|
||||||
return value === null ? "Brak danych" : `${value.toFixed(2)} m³/s`;
|
return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(value: string | null, fallback = "Brak danych") {
|
export function formatDateTime(value: string | null, language: Language = "pl", fallback = translate(language, "common.noData")) {
|
||||||
if (!value) return fallback;
|
if (!value) return fallback;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return fallback;
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
return new Intl.DateTimeFormat(polishLocale, {
|
return new Intl.DateTimeFormat(locales[language], {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -167,12 +168,12 @@ export function getWeatherMoodFromData(station: SynopStation, date = new Date())
|
|||||||
return "mild";
|
return "mild";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWeatherDescription(station: SynopStation) {
|
export function getWeatherDescription(station: SynopStation, language: Language = "pl") {
|
||||||
if ((station.rainfall ?? 0) >= 5) return "Wyraźne opady";
|
if ((station.rainfall ?? 0) >= 5) return translate(language, "weather.heavyRain");
|
||||||
if ((station.rainfall ?? 0) >= 0.1) return "Opady";
|
if ((station.rainfall ?? 0) >= 0.1) return translate(language, "weather.rain");
|
||||||
if ((station.windSpeed ?? 0) >= 8) return "Silny wiatr";
|
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
|
||||||
if ((station.humidity ?? 0) >= 90) return "Wilgotno";
|
if ((station.humidity ?? 0) >= 90) return translate(language, "weather.humid");
|
||||||
return "Spokojne warunki";
|
return translate(language, "weather.calm");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moodGradient(mood: WeatherMood) {
|
export function moodGradient(mood: WeatherMood) {
|
||||||
|
|||||||
Reference in New Issue
Block a user