chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s

This commit is contained in:
zv
2026-06-14 20:26:56 +02:00
parent 8bbd9397a1
commit ee55521803
79 changed files with 2451 additions and 969 deletions

View File

@@ -1,7 +1,15 @@
"use client";
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 { MetricCard } from "@/components/weather/metric-card";
import { useI18n } from "@/lib/i18n";
@@ -13,17 +21,57 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
const metrics = [
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language, temperatureUnit), detail: t("weather.feelsLikeDetail") },
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") },
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") },
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") },
{ 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: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") },
{ icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language, temperatureUnit), detail: t("weather.temperatureDetail") },
{
icon: Thermometer,
label: t("weather.feelsLike"),
value: formatTemperature(feelsLike, language, temperatureUnit),
detail: t("weather.feelsLikeDetail"),
},
{
icon: Droplets,
label: t("weather.humidity"),
value: formatHumidity(station.humidity, language),
detail: t("weather.humidityDetail"),
},
{
icon: Gauge,
label: t("weather.pressure"),
value: formatPressure(station.pressure, language),
detail: t("weather.pressureDetail"),
},
{
icon: Wind,
label: t("weather.windSpeed"),
value: formatWind(station.windSpeed, null, language, windSpeedUnit),
detail: t("weather.windSpeedDetail"),
},
{
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: t("weather.rainfallTotal"),
value: formatRainfall(station.rainfall, language),
detail: t("weather.rainfallDetail"),
},
{
icon: CloudSun,
label: t("weather.airTemperature"),
value: formatTemperature(station.temperature, language, temperatureUnit),
detail: t("weather.temperatureDetail"),
},
];
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric, index) => <MetricCard {...metric} index={index} key={metric.label} />)}
{metrics.map((metric, index) => (
<MetricCard {...metric} index={index} key={metric.label} />
))}
</div>
);
}

View File

@@ -67,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
},
(error) => {
setIsLocating(false);
setMessage(error.code === error.PERMISSION_DENIED
? t("location.gpsDenied")
: error.code === error.TIMEOUT
? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable"));
setMessage(
error.code === error.PERMISSION_DENIED
? t("location.gpsDenied")
: error.code === error.TIMEOUT
? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable"),
);
},
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
);
@@ -87,11 +89,14 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
useEffect(() => {
if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return;
navigator.permissions.query({ name: "geolocation" }).then((permission) => {
if (permission.state !== "granted" || autoLocated.current) return;
autoLocated.current = true;
locate();
}).catch(() => undefined);
navigator.permissions
.query({ name: "geolocation" })
.then((permission) => {
if (permission.state !== "granted" || autoLocated.current) return;
autoLocated.current = true;
locate();
})
.catch(() => undefined);
}, [locate]);
return (
@@ -99,17 +104,27 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{showPrompt && (
<div className="glass-subtle rounded-card p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-control bg-accent/10 p-2 text-accent"><MapPinned className="size-4" /></div>
<div className="rounded-control bg-accent/10 p-2 text-accent">
<MapPinned className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
{!isSecureContext && (
<p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" />
{t("location.gpsSecureContext")}
</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</Button>
<Button type="button" variant="ghost" onClick={dismissPrompt}><X className="size-4" />{t("location.gpsNotNow")}</Button>
<Button type="button" variant="ghost" onClick={dismissPrompt}>
<X className="size-4" />
{t("location.gpsNotNow")}
</Button>
</div>
</div>
</div>
@@ -121,11 +136,23 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button>
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">{message}</p>}
{message && (
<p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">
{message}
</p>
)}
</div>
)}
<p className="text-[0.68rem] text-muted">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a>
{t("location.gpsAttribution")}{" "}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
OpenStreetMap <ExternalLink className="size-3" />
</a>
</p>
</div>
);

View File

@@ -14,8 +14,15 @@ export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
return (
<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" />{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="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>
</section>
);
}

View File

@@ -19,7 +19,10 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return (
<section className="space-y-3">
<div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("featured.label")}</p>
<p className="section-kicker flex items-center gap-2">
<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">
@@ -30,11 +33,16 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
type="button"
key={station.id}
onClick={() => selectStation(station.id)}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active && "border-accent/60 bg-surface-raised/90")}
className={cn(
"glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active && "border-accent/60 bg-surface-raised/90",
)}
>
<span className="min-w-0">
<span className="block truncate text-xs font-medium text-muted">{station.name}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language, temperatureUnit)}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">
{formatTemperature(station.temperature, language, temperatureUnit)}
</span>
</span>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
</button>

View File

@@ -10,7 +10,13 @@ import { useWeatherStore } from "@/lib/store";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
export function LocationSearch({
stations,
positions,
}: {
stations: SynopStation[];
positions: MeteoStationPosition[];
}) {
const { language, t } = useI18n();
const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false);
@@ -18,12 +24,17 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
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,
selected: createSelectedLocation(location, locatedStations),
})), [locatedStations, locations]);
const suggestions = useMemo(
() =>
(locations ?? []).map((location) => ({
location,
selected: createSelectedLocation(location, locatedStations),
})),
[locatedStations, locations],
);
const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
const isPreparingStations =
positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
function chooseLocation(location: SelectedLocation) {
selectLocation(location);
@@ -48,7 +59,11 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<div className="relative z-20">
<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-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />}
{isFetching || isPreparingStations ? (
<LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" />
) : (
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />
)}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -59,31 +74,57 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
autoComplete="off"
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
/>
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>}
{query && (
<button
type="button"
aria-label={t("location.clear")}
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X className="size-4" />
</button>
)}
</label>
{showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
suggestions.map(({ location, selected }) => (
<button
type="button"
key={location.id}
onClick={() => chooseLocation(selected)}
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span>
<span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-muted">{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}</span>
{isPreparingStations ? (
<p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p>
) : isError ? (
<p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p>
) : !isFetching && !suggestions.length ? (
<p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p>
) : (
suggestions.map(({ location, selected }) => (
<button
type="button"
key={location.id}
onClick={() => chooseLocation(selected)}
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span>
<span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-muted">
{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}
</span>
{selected.stationName && selected.distanceKm !== null ? (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{selected.stationName} · {selected.distanceKm} km</strong></span>
) : (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.modelSource")}<br /><strong className="font-semibold text-foreground">Open-Meteo</strong></span>
)}
</button>
))}
</span>
{selected.stationName && selected.distanceKm !== null ? (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">
{t("location.nearest")}
<br />
<strong className="font-semibold text-foreground">
{selected.stationName} · {selected.distanceKm} km
</strong>
</span>
) : (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">
{t("location.modelSource")}
<br />
<strong className="font-semibold text-foreground">Open-Meteo</strong>
</span>
)}
</button>
))
)}
</div>
)}
</div>
@@ -91,12 +132,24 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted">
{selectedLocation.stationName && selectedLocation.distanceKm !== null
? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })
? t("location.currentSource", {
location: selectedLocation.name,
station: selectedLocation.stationName,
distance: selectedLocation.distanceKm,
})
: t("location.currentSourceGlobal", { location: selectedLocation.name })}
</p>
)}
<p className="mt-3 px-1 text-[0.68rem] text-muted">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a>
{t("location.attribution")}{" "}
<a
href="https://open-meteo.com/en/docs/geocoding-api"
target="_blank"
rel="noreferrer"
className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
Open-Meteo / GeoNames
</a>
</p>
</div>
</section>

View File

@@ -4,9 +4,25 @@ import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { icon: LucideIcon; label: string; value: string; detail?: string; index?: number }) {
export function MetricCard({
icon: Icon,
label,
value,
detail,
index = 0,
}: {
icon: LucideIcon;
label: string;
value: string;
detail?: string;
index?: number;
}) {
return (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04, duration: 0.35 }}
>
<Card className="h-full p-4 sm:p-5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" />

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { Droplets, Gauge, Heart, Wind } from "lucide-react";
import { useWeatherStore } from "@/lib/store";
import { formatHumidity, formatPressure, formatTemperature, formatWind, getWeatherMoodFromData } from "@/lib/weather-utils";
import {
formatHumidity,
formatPressure,
formatTemperature,
formatWind,
getWeatherMoodFromData,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -23,24 +29,56 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const mood = getWeatherMoodFromData(station);
const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}
>
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<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-accent">
<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-accent"
>
<p className="truncate text-sm font-semibold">{station.name}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language, temperatureUnit)}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">
{formatTemperature(station.temperature, language, temperatureUnit)}
</p>
</Link>
<div className="flex flex-col items-end gap-2">
<WeatherIcon mood={mood} className="size-9 text-accent" />
<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)}>
<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")} />
</Button>
</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-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<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"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
<Link
href={`/station/${station.id}`}
onClick={() => selectStation(station.id)}
className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<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">
<Gauge className="size-3" />
{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}
</span>
</Link>
</Card>
</motion.div>

View File

@@ -27,7 +27,13 @@ export function StationDetailPage({ id }: { id: string }) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/" className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><ArrowLeft className="size-4" />{t("station.all")}</Link>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<ArrowLeft className="size-4" />
{t("station.all")}
</Link>
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")}
@@ -43,12 +49,21 @@ export function StationDetailPage({ id }: { id: string }) {
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} />
<Card className="p-5">
<div className="section-kicker flex items-center gap-2"><ShieldCheck className="size-5" /><p>{t("station.quality")}</p></div>
<div className="section-kicker flex items-center gap-2">
<ShieldCheck className="size-5" />
<p>{t("station.quality")}</p>
</div>
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2>
<p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<dl className="mt-6 space-y-3 text-sm">
<div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
<div>
<dt className="text-muted">{t("station.lastMeasurement")}</dt>
<dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd>
</div>
<div>
<dt className="text-muted">{t("station.source")}</dt>
<dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd>
</div>
</dl>
</Card>
</div>

View File

@@ -43,7 +43,13 @@ const lightningBolts = [
},
];
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) {
export function WeatherEffects({
precipitation10m,
thunderstorm = false,
}: {
precipitation10m?: number | null;
thunderstorm?: boolean;
}) {
const reduceMotion = useReducedMotion();
const isRaining = (precipitation10m ?? 0) > 0;
@@ -55,7 +61,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span
key={`rain-${index}`}
initial={{ y: "-18vh", opacity: 0 }}
animate={reduceMotion ? { y: `${(index % 8) * 14}vh`, opacity: 0.28 } : { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }}
animate={
reduceMotion
? { y: `${(index % 8) * 14}vh`, opacity: 0.28 }
: { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }
}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className={`absolute -top-12 w-0.5 rotate-[10deg] rounded-full bg-foreground/70 shadow-[0_0_8px_hsl(var(--foreground)/0.18)] ${drop.lengthClass}`}
style={{ left: drop.left }}
@@ -65,7 +75,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span
key={`mist-${index}`}
initial={{ y: "-10vh", opacity: 0 }}
animate={reduceMotion ? { y: `${(index % 6) * 18}vh`, opacity: 0.16 } : { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }}
animate={
reduceMotion
? { y: `${(index % 6) * 18}vh`, opacity: 0.16 }
: { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }
}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className="absolute -top-8 h-7 w-px rotate-[10deg] rounded-full bg-accent/55"
style={{ left: drop.left }}
@@ -80,7 +94,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
key={`lightning-${index}`}
viewBox="0 0 96 176"
preserveAspectRatio="xMidYMid meet"
animate={reduceMotion ? { opacity: bolt.opacity * 0.22 } : { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }}
animate={
reduceMotion
? { opacity: bolt.opacity * 0.22 }
: { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }
}
transition={{ duration: 3.6, repeat: Infinity, repeatDelay: 2.4, delay: bolt.delay }}
className={`absolute text-foreground dark:text-white ${bolt.className}`}
>

View File

@@ -31,7 +31,21 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
return null;
}
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number | null }) {
export function WeatherHero({
station,
currentWeather,
currentWeatherLoading = false,
currentForecastWeatherCode,
locationName,
distanceKm,
}: {
station: SynopStation;
currentWeather?: ImgwCurrentWeather | null;
currentWeatherLoading?: boolean;
currentForecastWeatherCode?: number | null;
locationName?: string;
distanceKm?: number | null;
}) {
const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
@@ -40,35 +54,59 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
const hasGlobalModel = currentWeather?.coverage === "global-model";
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30;
const displayedStation = currentWeather ? {
...station,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station;
const hasDistantFallback =
!hasGlobalModel &&
!hasFullHybridAnalysis &&
!currentWeatherLoading &&
distanceKm !== undefined &&
distanceKm !== null &&
distanceKm >= 30;
const displayedStation = currentWeather
? {
...station,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall,
}
: station;
const mood = getWeatherMoodFromData(displayedStation);
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const feelsLike =
currentWeather?.feelsLike ??
calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) },
{ icon: Umbrella, label: hasGlobalModel ? t("weather.currentPrecipitation") : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
{
icon: Wind,
label: t("weather.wind"),
value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit),
},
{
icon: Umbrella,
label: hasGlobalModel
? t("weather.currentPrecipitation")
: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined
? t("weather.rainfall10m")
: t("weather.rainfallTotal"),
value: formatRainfall(displayedStation.rainfall, language),
},
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
];
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
? 0.1
: devEffectOverride === "none"
? 0
: currentWeather?.precipitation10m;
const effectThunderstorm = devEffectOverride === "thunderstorm"
? true
: devEffectOverride === "rain" || devEffectOverride === "none"
? false
: currentWeather?.condition === "thunderstorm";
const effectPrecipitation10m =
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
? 0.1
: devEffectOverride === "none"
? 0
: currentWeather?.precipitation10m;
const effectThunderstorm =
devEffectOverride === "thunderstorm"
? true
: devEffectOverride === "rain" || devEffectOverride === "none"
? false
: currentWeather?.condition === "thunderstorm";
const weatherDescription = getWeatherDescription(
displayedStation,
language,
@@ -97,22 +135,37 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
<div className="relative z-10">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted"><MapPin className="size-4" />{displayedLocationName}</span>
<span className="flex items-center gap-1.5 text-sm font-medium text-muted">
<MapPin className="size-4" />
{displayedLocationName}
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted">
<p>{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name })
: hasGlobalModel
? t("location.heroGlobalModelSource", { location: displayedLocationName })
: hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
: t("location.heroStationFallback", { station: station.name })}</p>
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
{hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
<p>
{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name })
: hasGlobalModel
? t("location.heroGlobalModelSource", { location: displayedLocationName })
: hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName
? t("location.heroStationFallbackWithDistance", {
station: station.name,
distance: distanceKm ?? 0,
})
: t("location.heroStationFallback", { station: station.name })}
</p>
{hasFullHybridAnalysis && locationName && (
<p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>
)}
{hasDistantFallback && (
<p className="flex items-start gap-1.5 text-warning">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
{t("location.heroDistantFallback")}
</p>
)}
</div>
</div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
@@ -121,14 +174,25 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
</div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
<p className="mt-1 text-sm text-muted">
{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} ·{" "}
{hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")}{" "}
{formatDateTime(displayedStation.measuredAt, language)}
</p>
</div>
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
<WeatherIcon
mood={mood}
condition={currentWeather?.condition}
className="mb-4 size-20 text-accent sm:size-28"
/>
</div>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5">
<div className="flex items-center gap-2 text-xs text-muted"><Icon className="size-3.5 text-accent" />{label}</div>
<div className="flex items-center gap-2 text-xs text-muted">
<Icon className="size-3.5 text-accent" />
{label}
</div>
<p className="mt-2 text-base font-semibold">{value}</p>
</div>
))}

View File

@@ -2,14 +2,29 @@ import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, Thermo
import type { WeatherMood } from "@/types/imgw";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) {
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : {
warm: ThermometerSun,
cloudy: Cloud,
wind: Wind,
cold: Snowflake,
night: MoonStar,
mild: CloudSun,
}[mood];
export function WeatherIcon({
mood,
condition,
className = "",
}: {
mood: WeatherMood;
condition?: CurrentWeatherCondition;
className?: string;
}) {
const Icon =
condition === "thunderstorm"
? CloudLightning
: condition === "rain"
? CloudRain
: condition === "snow"
? Snowflake
: {
warm: ThermometerSun,
cloudy: Cloud,
wind: Wind,
cold: Snowflake,
night: MoonStar,
mild: CloudSun,
}[mood];
return <Icon className={className} strokeWidth={1.35} />;
}