feat: redesign dashboard around place search
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
|
||||
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.
|
||||
|
||||
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js z App Router i TypeScript
|
||||
@@ -48,6 +50,8 @@ Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW:
|
||||
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
|
||||
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
|
||||
|
||||
Do wyszukiwania nazw miejscowości, bez pobierania danych pogodowych, używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić geokoder własnym dostawcą.
|
||||
|
||||
Przeglądarka pobiera dane przez whitelistowane proxy w `app/api/imgw/[...path]/route.ts`. Pozwala to ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content.
|
||||
|
||||
## Ograniczenia API
|
||||
|
||||
46
app/api/locations/search/route.ts
Normal file
46
app/api/locations/search/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
|
||||
|
||||
interface RawLocation {
|
||||
id?: number;
|
||||
name?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
admin1?: string;
|
||||
admin2?: string;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("query")?.trim() ?? "";
|
||||
const language = searchParams.get("language") === "en" ? "en" : "pl";
|
||||
if (query.length < 2 || query.length > 80) return NextResponse.json([]);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
name: query,
|
||||
count: "8",
|
||||
language,
|
||||
format: "json",
|
||||
countryCode: "PL",
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
|
||||
if (!response.ok) return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
|
||||
const data = await response.json() as { results?: RawLocation[] };
|
||||
const results = (data.results ?? []).flatMap((location) => {
|
||||
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return [];
|
||||
return [{
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
latitude: location.latitude as number,
|
||||
longitude: location.longitude as number,
|
||||
province: location.admin1 ?? null,
|
||||
district: location.admin2 ?? null,
|
||||
}];
|
||||
});
|
||||
return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,32 @@ import { useWeatherStore } from "@/lib/store";
|
||||
import { useWeatherStations } from "@/hooks/use-weather-stations";
|
||||
import { WeatherHero } from "@/components/weather/weather-hero";
|
||||
import { FavoritesSection } from "@/components/weather/favorites-section";
|
||||
import { StationsExplorer } from "@/components/weather/stations-explorer";
|
||||
import { LocationSearch } from "@/components/weather/location-search";
|
||||
import { FeaturedStationsSection } from "@/components/weather/featured-stations-section";
|
||||
import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||
import { ErrorState } from "@/components/states/error-state";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: stations, isPending, isError, refetch } = useWeatherStations();
|
||||
const { data: positions = [] } = useMeteoStationPositions();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError || !stations?.length) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
||||
const selectedStation = stations.find((station) => station.id === selectedStationId)
|
||||
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||
?? stations[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<WeatherHero station={selectedStation} />
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<FavoritesSection stations={stations} />
|
||||
<StationsExplorer stations={stations} />
|
||||
<FeaturedStationsSection stations={stations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
45
components/weather/featured-stations-section.tsx
Normal file
45
components/weather/featured-stations-section.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { MapPinned } from "lucide-react";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { formatTemperature, getWeatherMoodFromData } from "@/lib/weather-utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const featuredNames = ["Warszawa", "Kraków", "Gdańsk", "Wrocław", "Poznań", "Zakopane"];
|
||||
|
||||
export function FeaturedStationsSection({ stations }: { stations: SynopStation[] }) {
|
||||
const { language, t } = useI18n();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectStation = useWeatherStore((state) => state.selectStation);
|
||||
const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []);
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><MapPinned className="size-4" />{t("featured.label")}</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{featured.map((station) => {
|
||||
const active = selectedStationId === station.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={station.id}
|
||||
onClick={() => selectStation(station.id)}
|
||||
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-2xl p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10", active && "border-sky-400/60 bg-white/60 dark:bg-white/15")}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-medium text-slate-600 dark:text-slate-300">{station.name}</span>
|
||||
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span>
|
||||
</span>
|
||||
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
83
components/weather/location-search.tsx
Normal file
83
components/weather/location-search.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
|
||||
import { useLocationSearch } from "@/hooks/use-location-search";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||
|
||||
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
||||
const { language, t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const selectLocation = useWeatherStore((state) => state.selectLocation);
|
||||
const { data: locations, isFetching, isError } = useLocationSearch(query, language);
|
||||
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
|
||||
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
|
||||
location,
|
||||
nearest: findNearestSynopStation(location, locatedStations),
|
||||
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]);
|
||||
const showSuggestions = isFocused && query.trim().length >= 2;
|
||||
const isPreparingStations = positions.length === 0;
|
||||
|
||||
return (
|
||||
<section className="relative z-30">
|
||||
<div className="glass rounded-[1.75rem] p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 px-1 pb-3">
|
||||
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
|
||||
</div>
|
||||
<label className="relative block">
|
||||
<span className="sr-only">{t("location.searchLabel")}</span>
|
||||
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-sky-600" /> : <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) => setQuery(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
||||
placeholder={t("location.placeholder")}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-2xl border border-white/40 bg-white/55 py-3.5 pl-10 pr-10 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/10"
|
||||
/>
|
||||
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
|
||||
</label>
|
||||
{selectedLocation && (
|
||||
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
|
||||
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo / GeoNames</a>
|
||||
</p>
|
||||
</div>
|
||||
{showSuggestions && (
|
||||
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
|
||||
{isPreparingStations ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
|
||||
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.error")}</p> :
|
||||
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.empty")}</p> :
|
||||
suggestions.map(({ location, nearest }) => nearest && (
|
||||
<button
|
||||
type="button"
|
||||
key={location.id}
|
||||
onClick={() => {
|
||||
selectLocation(nearest);
|
||||
setQuery("");
|
||||
setIsFocused(false);
|
||||
}}
|
||||
className="flex w-full items-start justify-between gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{location.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">{t("location.nearest")}<br /><strong className="font-semibold text-slate-700 dark:text-slate-200">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import { StationCard } from "@/components/weather/station-card";
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
import { SearchX } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function StationGrid({ stations }: { stations: SynopStation[] }) {
|
||||
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>;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Search, SlidersHorizontal } from "lucide-react";
|
||||
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 }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="glass grid gap-3 rounded-[1.75rem] p-3 sm:grid-cols-[1fr_auto_auto]">
|
||||
<label className="relative">
|
||||
<span className="sr-only">{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" />
|
||||
<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 className="relative">
|
||||
<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">
|
||||
<option value="alphabetical">{t("stations.sortAlphabetical")}</option>
|
||||
<option value="temperature-desc">{t("stations.sortTemperatureDesc")}</option>
|
||||
<option value="temperature-asc">{t("stations.sortTemperatureAsc")}</option>
|
||||
<option value="humidity-desc">{t("stations.sortHumidityDesc")}</option>
|
||||
<option value="pressure-desc">{t("stations.sortPressureDesc")}</option>
|
||||
</select>
|
||||
<SlidersHorizontal className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="sr-only">{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">
|
||||
<option value="all">{t("stations.filterAll")}</option>
|
||||
<option value="warmest">{t("stations.filterWarmest")}</option>
|
||||
<option value="coldest">{t("stations.filterColdest")}</option>
|
||||
<option value="windy">{t("stations.filterWindy")}</option>
|
||||
<option value="rainy">{t("stations.filterRainy")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import { StationGrid } from "@/components/weather/station-grid";
|
||||
import { StationSearch } from "@/components/weather/station-search";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export type StationSort = "alphabetical" | "temperature-desc" | "temperature-asc" | "humidity-desc" | "pressure-desc";
|
||||
export type StationFilter = "all" | "warmest" | "coldest" | "windy" | "rainy";
|
||||
|
||||
function compareNumbers(a: number | null, b: number | null, direction: "asc" | "desc") {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return direction === "asc" ? a - b : b - a;
|
||||
}
|
||||
|
||||
export function StationsExplorer({ stations }: { stations: SynopStation[] }) {
|
||||
const { locale, t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<StationSort>("alphabetical");
|
||||
const [filter, setFilter] = useState<StationFilter>("all");
|
||||
const visibleStations = useMemo(() => {
|
||||
const searched = stations.filter((station) => station.name.toLocaleLowerCase(locale).includes(query.trim().toLocaleLowerCase(locale)));
|
||||
const sorted = [...searched].sort((a, b) => {
|
||||
if (sort === "temperature-desc") return compareNumbers(a.temperature, b.temperature, "desc");
|
||||
if (sort === "temperature-asc") return compareNumbers(a.temperature, b.temperature, "asc");
|
||||
if (sort === "humidity-desc") return compareNumbers(a.humidity, b.humidity, "desc");
|
||||
if (sort === "pressure-desc") return compareNumbers(a.pressure, b.pressure, "desc");
|
||||
return a.name.localeCompare(b.name, locale);
|
||||
});
|
||||
if (filter === "all") return sorted;
|
||||
const key = { warmest: "temperature", coldest: "temperature", windy: "windSpeed", rainy: "rainfall" }[filter] as keyof SynopStation;
|
||||
return [...sorted].sort((a, b) => compareNumbers(a[key] as number | null, b[key] as number | null, filter === "coldest" ? "asc" : "desc")).slice(0, 12);
|
||||
}, [filter, locale, query, sort, stations]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("stations.section")}</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight">{t("stations.title")}</h2>
|
||||
</div>
|
||||
<StationSearch query={query} onQueryChange={setQuery} sort={sort} onSortChange={setSort} filter={filter} onFilterChange={setFilter} />
|
||||
<StationGrid stations={visibleStations} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import type { SynopStation } from "@/types/imgw";
|
||||
import { WeatherIcon } from "@/components/weather/weather-icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function WeatherHero({ station }: { station: SynopStation }) {
|
||||
export function WeatherHero({ station, locationName, distanceKm }: { station: SynopStation; locationName?: string; distanceKm?: number }) {
|
||||
const { language, t } = useI18n();
|
||||
const mood = getWeatherMoodFromData(station);
|
||||
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||
@@ -40,7 +40,8 @@ export function WeatherHero({ station }: { station: SynopStation }) {
|
||||
<div className="absolute -bottom-24 -left-16 size-72 rounded-full bg-cyan-200/15 blur-3xl" />
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{station.name}</span>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{locationName ?? station.name}</span>
|
||||
{locationName && <span className="text-xs text-white/65">{t("location.heroSource", { station: station.name, distance: distanceKm ?? 0 })}</span>}
|
||||
</div>
|
||||
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
||||
<div>
|
||||
|
||||
21
hooks/use-location-search.ts
Normal file
21
hooks/use-location-search.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchLocations } from "@/lib/location-api";
|
||||
import type { Language } from "@/lib/i18n";
|
||||
|
||||
export function useLocationSearch(query: string, language: Language) {
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedQuery(query.trim()), 300);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [query]);
|
||||
return useQuery({
|
||||
queryKey: ["location-search", debouncedQuery, language],
|
||||
queryFn: ({ signal }) => fetchLocations(debouncedQuery, language, signal),
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
15
hooks/use-meteo-stations.ts
Normal file
15
hooks/use-meteo-stations.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchMeteoStationPositions } from "@/lib/imgw-api";
|
||||
import { QUERY_GC_TIME, QUERY_STALE_TIME } from "@/lib/constants";
|
||||
|
||||
export function useMeteoStationPositions() {
|
||||
return useQuery({
|
||||
queryKey: ["meteo-station-positions"],
|
||||
queryFn: ({ signal }) => fetchMeteoStationPositions(signal),
|
||||
staleTime: QUERY_STALE_TIME,
|
||||
gcTime: QUERY_GC_TIME,
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
60
lib/i18n.tsx
60
lib/i18n.tsx
@@ -25,6 +25,19 @@ const translations = {
|
||||
"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.",
|
||||
"location.label": "Twoja lokalizacja",
|
||||
"location.searchLabel": "Szukaj miejscowości w Polsce",
|
||||
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
|
||||
"location.clear": "Wyczyść wyszukiwanie",
|
||||
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
|
||||
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
|
||||
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
|
||||
"location.nearest": "Najbliższa stacja IMGW",
|
||||
"location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
|
||||
"location.heroSource": "stacja IMGW: {station} · około {distance} km",
|
||||
"location.attribution": "Wyszukiwanie miejscowości:",
|
||||
"featured.label": "Szybki wybór",
|
||||
"featured.title": "Wybrane stacje IMGW",
|
||||
"favorites.title": "Ulubione lokalizacje",
|
||||
"favorites.empty": "Dodaj stacje do ulubionych, aby mieć ich odczyty pod ręką.",
|
||||
"favorites.addStation": "Dodaj {name} do ulubionych",
|
||||
@@ -53,24 +66,7 @@ const translations = {
|
||||
"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",
|
||||
@@ -139,6 +135,19 @@ const translations = {
|
||||
"error.title": "Unable to load data",
|
||||
"error.description": "Check your connection and try again.",
|
||||
"dashboard.error": "Unable to load the IMGW synoptic station list.",
|
||||
"location.label": "Your location",
|
||||
"location.searchLabel": "Search places in Poland",
|
||||
"location.placeholder": "Enter a place, e.g. Piaseczno…",
|
||||
"location.clear": "Clear search",
|
||||
"location.error": "Unable to search for places. Try again.",
|
||||
"location.preparing": "Preparing the nearest IMGW stations…",
|
||||
"location.empty": "No matching place was found in Poland.",
|
||||
"location.nearest": "Nearest IMGW station",
|
||||
"location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
|
||||
"location.heroSource": "IMGW station: {station} · approximately {distance} km",
|
||||
"location.attribution": "Place search:",
|
||||
"featured.label": "Quick select",
|
||||
"featured.title": "Selected IMGW stations",
|
||||
"favorites.title": "Favourite locations",
|
||||
"favorites.empty": "Add stations to favourites to keep their readings close at hand.",
|
||||
"favorites.addStation": "Add {name} to favourites",
|
||||
@@ -167,24 +176,7 @@ const translations = {
|
||||
"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",
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
} from "@/lib/weather-utils";
|
||||
import type {
|
||||
HydroStation,
|
||||
MeteoStationPosition,
|
||||
RawHydroStation,
|
||||
RawMeteoStation,
|
||||
RawSynopStation,
|
||||
RawWarning,
|
||||
SynopStation,
|
||||
@@ -39,6 +41,16 @@ export async function fetchHydroStations(signal?: AbortSignal): Promise<HydroSta
|
||||
return rows.map(normalizeHydroStation).filter((station): station is HydroStation => station !== null);
|
||||
}
|
||||
|
||||
export async function fetchMeteoStationPositions(signal?: AbortSignal): Promise<MeteoStationPosition[]> {
|
||||
const rows = await getJson<RawMeteoStation[]>("meteo", signal);
|
||||
return rows.flatMap((row) => {
|
||||
const latitude = Number(row.lat);
|
||||
const longitude = Number(row.lon);
|
||||
if (!row.nazwa_stacji?.trim() || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
|
||||
return [{ name: row.nazwa_stacji, latitude, longitude }];
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWarningsByKind(kind: WarningKind, signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||
const rows = await getJson<RawWarning[]>(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal);
|
||||
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];
|
||||
|
||||
9
lib/location-api.ts
Normal file
9
lib/location-api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { LocationSearchResult } from "@/types/location";
|
||||
|
||||
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> {
|
||||
const params = new URLSearchParams({ query, language });
|
||||
const response = await fetch(`/api/locations/search?${params}`, { signal });
|
||||
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
|
||||
return response.json() as Promise<LocationSearchResult[]>;
|
||||
}
|
||||
66
lib/location-utils.ts
Normal file
66
lib/location-utils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
|
||||
|
||||
const stationAliases: Record<string, string> = {
|
||||
gdansk: "gdanskrebiechowo",
|
||||
gorzow: "gorzowwielkopolski",
|
||||
katowice: "katowicemuchowiec",
|
||||
kielce: "kielcesukow",
|
||||
kolo: "koloradoszewice",
|
||||
kolobrzeg: "kolobrzegdzwirzyno",
|
||||
krakow: "krakowbalice",
|
||||
lublin: "lublinradawiec",
|
||||
lodz: "lodzlublinek",
|
||||
poznan: "poznanlawica",
|
||||
resko: "reskosmolsko",
|
||||
rzeszow: "rzeszowjasionka",
|
||||
warszawa: "warszawaokecie",
|
||||
};
|
||||
|
||||
function normalizeName(value: string) {
|
||||
return value
|
||||
.replace(/[Łł]/g, "l")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
|
||||
const earthRadiusKm = 6371;
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const latitudeDistance = toRadians(latitudeB - latitudeA);
|
||||
const longitudeDistance = toRadians(longitudeB - longitudeA);
|
||||
const a = Math.sin(latitudeDistance / 2) ** 2 +
|
||||
Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2;
|
||||
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export interface LocatedSynopStation extends SynopStation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function locateSynopStations(stations: SynopStation[], positions: MeteoStationPosition[]) {
|
||||
const positionsByName = new Map(positions.map((position) => [normalizeName(position.name), position]));
|
||||
return stations.flatMap<LocatedSynopStation>((station) => {
|
||||
const normalizedStation = normalizeName(station.name);
|
||||
const position = positionsByName.get(stationAliases[normalizedStation] ?? normalizedStation);
|
||||
return position ? [{ ...station, latitude: position.latitude, longitude: position.longitude }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function findNearestSynopStation(location: LocationSearchResult, stations: LocatedSynopStation[]): SelectedLocation | null {
|
||||
const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
|
||||
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
|
||||
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
|
||||
}, null);
|
||||
if (!nearest) return null;
|
||||
return {
|
||||
name: location.name,
|
||||
province: location.province,
|
||||
stationId: nearest.station.id,
|
||||
stationName: nearest.station.name,
|
||||
distanceKm: Math.round(nearest.distanceKm),
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { SelectedLocation } from "@/types/location";
|
||||
|
||||
interface WeatherStore {
|
||||
favorites: string[];
|
||||
selectedStationId: string | null;
|
||||
selectedLocation: SelectedLocation | null;
|
||||
toggleFavorite: (id: string) => void;
|
||||
selectStation: (id: string) => void;
|
||||
selectLocation: (location: SelectedLocation) => void;
|
||||
}
|
||||
|
||||
export const useWeatherStore = create<WeatherStore>()(
|
||||
@@ -15,13 +18,15 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
(set) => ({
|
||||
favorites: [],
|
||||
selectedStationId: null,
|
||||
selectedLocation: null,
|
||||
toggleFavorite: (id) =>
|
||||
set((state) => ({
|
||||
favorites: state.favorites.includes(id)
|
||||
? state.favorites.filter((favoriteId) => favoriteId !== id)
|
||||
: [...state.favorites, id],
|
||||
})),
|
||||
selectStation: (id) => set({ selectedStationId: id }),
|
||||
selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
|
||||
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
|
||||
}),
|
||||
{ name: "wtr:preferences" },
|
||||
),
|
||||
|
||||
@@ -23,6 +23,19 @@ export interface SynopStation {
|
||||
pressure: number | null;
|
||||
}
|
||||
|
||||
export interface RawMeteoStation {
|
||||
kod_stacji?: string | null;
|
||||
nazwa_stacji?: string | null;
|
||||
lon?: string | null;
|
||||
lat?: string | null;
|
||||
}
|
||||
|
||||
export interface MeteoStationPosition {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface RawHydroStation {
|
||||
id_stacji?: string | null;
|
||||
stacja?: string | null;
|
||||
|
||||
16
types/location.ts
Normal file
16
types/location.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface LocationSearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
province: string | null;
|
||||
district: string | null;
|
||||
}
|
||||
|
||||
export interface SelectedLocation {
|
||||
name: string;
|
||||
province: string | null;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
distanceKm: number;
|
||||
}
|
||||
Reference in New Issue
Block a user