feat: redesign dashboard around place search

This commit is contained in:
zv
2026-06-01 19:05:31 +02:00
parent 6c2e731c60
commit 0632c67beb
18 changed files with 374 additions and 139 deletions

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

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

View File

@@ -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>;
}

View File

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

View File

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

View File

@@ -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>