feat: redesign dashboard around place search
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user