chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
This commit is contained in:
@@ -33,13 +33,35 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
|
||||
<div className="mt-4 h-56 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={INITIAL_CHART_DIMENSION}
|
||||
>
|
||||
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
||||
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit={temperatureUnitLabel} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={3}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
unit={temperatureUnitLabel}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||
contentStyle={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||
background: CHART_COLORS.tooltipBackground,
|
||||
color: CHART_COLORS.tooltipText,
|
||||
}}
|
||||
formatter={(value) => [
|
||||
typeof value === "number"
|
||||
? formatTemperatureValue(value, language, temperatureUnit)
|
||||
@@ -47,8 +69,25 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
|
||||
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="temperature"
|
||||
name={t("forecast.temperature")}
|
||||
stroke={CHART_COLORS.temperature}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="feelsLike"
|
||||
name={t("forecast.apparentTemperature")}
|
||||
stroke={CHART_COLORS.feelsLike}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -58,14 +97,45 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
|
||||
<div className="mt-4 h-56 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={INITIAL_CHART_DIMENSION}
|
||||
>
|
||||
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
||||
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
|
||||
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={3}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="rainfall"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
unit=" mm"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="probability"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 11 }}
|
||||
unit="%"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||
contentStyle={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||
background: CHART_COLORS.tooltipBackground,
|
||||
color: CHART_COLORS.tooltipText,
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
name === t("forecast.precipitation")
|
||||
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
||||
@@ -74,8 +144,23 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} />
|
||||
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls />
|
||||
<Bar
|
||||
yAxisId="rainfall"
|
||||
dataKey="precipitation"
|
||||
name={t("forecast.precipitation")}
|
||||
fill={CHART_COLORS.rainfall}
|
||||
radius={[5, 5, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="probability"
|
||||
type="monotone"
|
||||
dataKey="precipitationProbability"
|
||||
name={t("forecast.precipitationProbability")}
|
||||
stroke={CHART_COLORS.probability}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -8,23 +8,28 @@ import { useWeatherStore } from "@/lib/store";
|
||||
import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
|
||||
|
||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||
const SNAPSHOT_COLORS = [
|
||||
"hsl(var(--chart-temperature))",
|
||||
"hsl(var(--chart-feels-like))",
|
||||
"hsl(var(--chart-rainfall))",
|
||||
];
|
||||
const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"];
|
||||
|
||||
export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||
const { t } = useI18n();
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const windDigits = windSpeedUnit === "ms" ? 1 : 0;
|
||||
const windSpeed = station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
|
||||
const windSpeed =
|
||||
station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
|
||||
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45;
|
||||
const rows = [
|
||||
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
|
||||
{ name: t("weather.wind"), value: windSpeed, unit: getWindSpeedUnitLabel(windSpeedUnit), max: windMax, color: SNAPSHOT_COLORS[1] },
|
||||
{
|
||||
name: t("weather.wind"),
|
||||
value: windSpeed,
|
||||
unit: getWindSpeedUnitLabel(windSpeedUnit),
|
||||
max: windMax,
|
||||
color: SNAPSHOT_COLORS[1],
|
||||
},
|
||||
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
|
||||
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||
]
|
||||
.filter((row) => row.value !== null)
|
||||
.map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
@@ -32,13 +37,31 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
|
||||
<div className="mt-5 h-52 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={INITIAL_CHART_DIMENSION}
|
||||
>
|
||||
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
||||
<XAxis type="number" hide domain={[0, 100]} />
|
||||
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={86}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "hsl(var(--border) / 0.22)" }}
|
||||
formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]}
|
||||
/>
|
||||
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
||||
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
|
||||
{rows.map((row) => (
|
||||
<Cell fill={row.color} key={row.name} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -27,44 +27,78 @@ export function DashboardPage() {
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const dashboardSections = useWeatherStore((state) => state.dashboardSections);
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||
?? stations?.[0];
|
||||
const selectedStation =
|
||||
stations?.find((station) => station.id === selectedStationId) ??
|
||||
stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
|
||||
stations?.[0];
|
||||
const activeLocation = selectedLocation;
|
||||
const activeRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||
const hasActiveLocationCoordinates =
|
||||
Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
|
||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude, activeRegion);
|
||||
const forecastLocationName = hasActiveLocationCoordinates
|
||||
? (activeLocation?.name ?? selectedStation?.name)
|
||||
: selectedStation?.name;
|
||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(
|
||||
forecastLatitude,
|
||||
forecastLongitude,
|
||||
activeRegion,
|
||||
);
|
||||
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion);
|
||||
const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null;
|
||||
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||
const currentForecastWeatherCode = forecast
|
||||
? (getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null)
|
||||
: null;
|
||||
const isCurrentWeatherLoading =
|
||||
Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
||||
if (isError || !stations?.length || !selectedStation)
|
||||
return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
||||
|
||||
const heroStation: SynopStation = activeLocation?.region === "GLOBAL" ? {
|
||||
id: `global:${activeLocation.latitude},${activeLocation.longitude}`,
|
||||
name: activeLocation.name,
|
||||
measuredAt: null,
|
||||
temperature: null,
|
||||
windSpeed: null,
|
||||
windDirection: null,
|
||||
humidity: null,
|
||||
rainfall: null,
|
||||
pressure: null,
|
||||
} : selectedStation;
|
||||
const heroStation: SynopStation =
|
||||
activeLocation?.region === "GLOBAL"
|
||||
? {
|
||||
id: `global:${activeLocation.latitude},${activeLocation.longitude}`,
|
||||
name: activeLocation.name,
|
||||
measuredAt: null,
|
||||
temperature: null,
|
||||
windSpeed: null,
|
||||
windDirection: null,
|
||||
humidity: null,
|
||||
rainfall: null,
|
||||
pressure: null,
|
||||
}
|
||||
: selectedStation;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
<WeatherHero station={heroStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<WeatherHero
|
||||
station={heroStation}
|
||||
currentWeather={currentWeather}
|
||||
currentWeatherLoading={isCurrentWeatherLoading}
|
||||
currentForecastWeatherCode={currentForecastWeatherCode}
|
||||
locationName={activeLocation?.name}
|
||||
distanceKm={activeLocation?.distanceKm}
|
||||
/>
|
||||
<DashboardWarnings />
|
||||
{dashboardSections.weatherBrief && <WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />}
|
||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
{dashboardSections.weatherBrief && (
|
||||
<WeatherBriefCard
|
||||
latitude={forecastLatitude}
|
||||
longitude={forecastLongitude}
|
||||
region={activeRegion}
|
||||
locationName={forecastLocationName ?? selectedStation.name}
|
||||
/>
|
||||
)}
|
||||
<ForecastPanel
|
||||
latitude={forecastLatitude}
|
||||
longitude={forecastLongitude}
|
||||
region={activeRegion}
|
||||
locationName={forecastLocationName ?? selectedStation.name}
|
||||
/>
|
||||
<FavoritesSection stations={stations} />
|
||||
{dashboardSections.featuredStations && <FeaturedStationsSection stations={stations} />}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,17 @@ import { useWeatherStore } from "@/lib/store";
|
||||
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export function WeatherBriefCard({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) {
|
||||
export function WeatherBriefCard({
|
||||
latitude,
|
||||
longitude,
|
||||
region = "PL",
|
||||
locationName,
|
||||
}: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
region?: WeatherRegion;
|
||||
locationName: string;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
|
||||
const { data: warnings = [] } = useWarnings();
|
||||
@@ -24,17 +34,57 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const isGlobalLocation = region === "GLOBAL";
|
||||
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const relevantWarnings = useMemo(() => isGlobalLocation ? [] : warnings, [isGlobalLocation, warnings]);
|
||||
const province = isGlobalLocation
|
||||
? null
|
||||
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const relevantWarnings = useMemo(() => (isGlobalLocation ? [] : warnings), [isGlobalLocation, warnings]);
|
||||
|
||||
const brief = useMemo(() => {
|
||||
if (!forecast) return null;
|
||||
return buildWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
|
||||
return buildWeatherBrief({
|
||||
forecast,
|
||||
warnings: relevantWarnings,
|
||||
province,
|
||||
countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
});
|
||||
}, [
|
||||
forecast,
|
||||
isGlobalLocation,
|
||||
language,
|
||||
locationName,
|
||||
province,
|
||||
relevantWarnings,
|
||||
selectedLocation?.countyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
]);
|
||||
const tomorrowBrief = useMemo(() => {
|
||||
if (!forecast) return null;
|
||||
return buildTomorrowWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
|
||||
return buildTomorrowWeatherBrief({
|
||||
forecast,
|
||||
warnings: relevantWarnings,
|
||||
province,
|
||||
countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
});
|
||||
}, [
|
||||
forecast,
|
||||
isGlobalLocation,
|
||||
language,
|
||||
locationName,
|
||||
province,
|
||||
relevantWarnings,
|
||||
selectedLocation?.countyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
]);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
||||
return <LoadingSkeleton className="h-48" />;
|
||||
@@ -61,11 +111,25 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
|
||||
return (
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={isWarning ? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning" : "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"}>
|
||||
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />}
|
||||
<div
|
||||
className={
|
||||
isWarning
|
||||
? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning"
|
||||
: "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"
|
||||
}
|
||||
>
|
||||
{isWarning ? (
|
||||
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<CloudSun className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"}>
|
||||
<p
|
||||
className={
|
||||
isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"
|
||||
}
|
||||
>
|
||||
{t("brief.label")}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
|
||||
@@ -75,7 +139,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{brief.body.slice(0, 4).map((line) => (
|
||||
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
|
||||
<li
|
||||
key={line}
|
||||
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
@@ -87,7 +154,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
|
||||
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{tomorrowBrief.body.slice(0, 3).map((line) => (
|
||||
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
|
||||
<li
|
||||
key={line}
|
||||
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -82,7 +82,9 @@ export function DayForecastModal({
|
||||
}, [day, onClose]);
|
||||
|
||||
const formattedDate = day
|
||||
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(new Date(`${day.date}T12:00:00Z`))
|
||||
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(
|
||||
new Date(`${day.date}T12:00:00Z`),
|
||||
)
|
||||
: "";
|
||||
|
||||
const modal = (
|
||||
@@ -114,7 +116,9 @@ export function DayForecastModal({
|
||||
<CloudSun className="size-4" />
|
||||
{t("forecast.dayDetails")}
|
||||
</p>
|
||||
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
|
||||
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{locationName}
|
||||
</h2>
|
||||
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -134,13 +138,25 @@ export function DayForecastModal({
|
||||
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
||||
<div className="mt-2 flex items-end gap-4">
|
||||
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</p>
|
||||
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</p>
|
||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">
|
||||
{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}
|
||||
</p>
|
||||
<p className="mb-2 text-2xl text-muted">
|
||||
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
||||
<DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} />
|
||||
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
|
||||
<DayMetric
|
||||
icon={Droplets}
|
||||
label={t("forecast.precipitation")}
|
||||
value={formatForecastRainfall(day.precipitation, language)}
|
||||
/>
|
||||
<DayMetric
|
||||
icon={Wind}
|
||||
label={t("forecast.maxWind")}
|
||||
value={formatForecastWind(maximumWind, language, windSpeedUnit)}
|
||||
/>
|
||||
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
|
||||
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
|
||||
</div>
|
||||
@@ -160,8 +176,14 @@ export function DayForecastModal({
|
||||
title={getForecastCondition(weatherCode, language)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||
<ForecastIcon code={weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" />
|
||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
|
||||
<ForecastIcon
|
||||
code={weatherCode}
|
||||
isNight={isForecastNight(hour.time, day)}
|
||||
className="mx-auto my-3 size-6 text-accent"
|
||||
/>
|
||||
<p className="text-lg font-semibold tracking-tight">
|
||||
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||
<Droplets className="size-3" />
|
||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudMoon, CloudRain, CloudSnow, CloudSun, MoonStar, Sun } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
CloudDrizzle,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
CloudSun,
|
||||
MoonStar,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ForecastIcon({ code, className = "", isNight = false }: { code: number | null; className?: string; isNight?: boolean }) {
|
||||
const Icon = code === 0 ? isNight ? MoonStar : Sun
|
||||
: code === 1 || code === 2 ? isNight ? CloudMoon : CloudSun
|
||||
: code === 45 || code === 48 ? CloudFog
|
||||
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle
|
||||
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain
|
||||
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow
|
||||
: code !== null && code >= 95 ? CloudLightning
|
||||
: Cloud;
|
||||
export function ForecastIcon({
|
||||
code,
|
||||
className = "",
|
||||
isNight = false,
|
||||
}: {
|
||||
code: number | null;
|
||||
className?: string;
|
||||
isNight?: boolean;
|
||||
}) {
|
||||
const Icon =
|
||||
code === 0
|
||||
? isNight
|
||||
? MoonStar
|
||||
: Sun
|
||||
: code === 1 || code === 2
|
||||
? isNight
|
||||
? CloudMoon
|
||||
: CloudSun
|
||||
: code === 45 || code === 48
|
||||
? CloudFog
|
||||
: code !== null && code >= 51 && code <= 57
|
||||
? CloudDrizzle
|
||||
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))
|
||||
? CloudRain
|
||||
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)
|
||||
? CloudSnow
|
||||
: code !== null && code >= 95
|
||||
? CloudLightning
|
||||
: Cloud;
|
||||
return <Icon className={className} strokeWidth={1.45} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, RefreshCw, ThermometerSun, Wind, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Droplets,
|
||||
RefreshCw,
|
||||
ThermometerSun,
|
||||
Wind,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||
@@ -71,24 +82,47 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
||||
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
|
||||
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
|
||||
const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
||||
const temperatureRange = minimumTemperature === null || maximumTemperature === null
|
||||
? "—"
|
||||
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
|
||||
const temperatureRange =
|
||||
minimumTemperature === null || maximumTemperature === null
|
||||
? "—"
|
||||
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
|
||||
|
||||
return (
|
||||
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{t("forecast.nextHoursOverview")}
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
|
||||
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
|
||||
<HourlySummaryMetric icon={CloudRain} label={t("forecast.rainfallTotal")} value={formatForecastRainfall(rainfallTotal, language)} />
|
||||
<HourlySummaryMetric icon={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} />
|
||||
<HourlySummaryMetric
|
||||
icon={Wind}
|
||||
label={t("forecast.maxWind")}
|
||||
value={formatForecastWind(maximumWind, language, windSpeedUnit)}
|
||||
/>
|
||||
<HourlySummaryMetric
|
||||
icon={CloudRain}
|
||||
label={t("forecast.rainfallTotal")}
|
||||
value={formatForecastRainfall(rainfallTotal, language)}
|
||||
/>
|
||||
<HourlySummaryMetric
|
||||
icon={Droplets}
|
||||
label={t("forecast.maxProbability")}
|
||||
value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
|
||||
function DailyForecastRow({
|
||||
day,
|
||||
index,
|
||||
onSelect,
|
||||
}: {
|
||||
day: DailyForecast;
|
||||
index: number;
|
||||
onSelect: (day: DailyForecast) => void;
|
||||
}) {
|
||||
const { language, locale, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const label = formatDay(day.date, locale, t("forecast.today"), index);
|
||||
@@ -109,17 +143,37 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
|
||||
<p className="text-sm font-semibold capitalize">{label}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
|
||||
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</span></p>
|
||||
<span className="hidden items-center gap-1 text-xs text-accent sm:flex">
|
||||
<Droplets className="size-3" />
|
||||
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}
|
||||
</span>
|
||||
<p className="whitespace-nowrap text-sm">
|
||||
<strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong>
|
||||
<span className="ml-2 text-muted">
|
||||
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
|
||||
</span>
|
||||
</p>
|
||||
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
||||
</motion.button>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForecastPanel({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) {
|
||||
export function ForecastPanel({
|
||||
latitude,
|
||||
longitude,
|
||||
region = "PL",
|
||||
locationName,
|
||||
}: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
region?: WeatherRegion;
|
||||
locationName: string;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
@@ -133,9 +187,14 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<p className="section-kicker flex items-center gap-2"><CloudSun className="size-4" />{t("forecast.label")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<CloudSun className="size-4" />
|
||||
{t("forecast.label")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">{t("forecast.description", { location: locationName })}</p>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">
|
||||
{t("forecast.description", { location: locationName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
|
||||
@@ -146,7 +205,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
|
||||
) : isError || !forecast ? (
|
||||
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-muted">{t("forecast.error")}</p>
|
||||
<Button variant="glass" className="mt-4" onClick={() => refetch()}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||
<Button variant="glass" className="mt-4" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-4" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : !forecast.hourly.length || !forecast.daily.length ? (
|
||||
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
|
||||
@@ -154,7 +216,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
|
||||
<div className="space-y-3">
|
||||
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
|
||||
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-accent" />{t("forecast.hourly")}</h3>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Clock3 className="size-4 text-accent" />
|
||||
{t("forecast.hourly")}
|
||||
</h3>
|
||||
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{upcomingHours.map((hour, index) => {
|
||||
@@ -170,11 +235,23 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
|
||||
title={getForecastCondition(weatherCode, language)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||
<ForecastIcon code={weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" />
|
||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
||||
<ForecastIcon
|
||||
code={weatherCode}
|
||||
isNight={isForecastNight(hour.time, day)}
|
||||
className="mx-auto my-3 size-6 text-accent"
|
||||
/>
|
||||
<p className="text-lg font-semibold tracking-tight">
|
||||
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||
<Droplets className="size-3" />
|
||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||
</p>
|
||||
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
||||
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
||||
<p
|
||||
className="flex items-center justify-center gap-1"
|
||||
title={t("forecast.apparentTemperature")}
|
||||
>
|
||||
<ThermometerSun className="size-3 text-accent" />
|
||||
{formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
|
||||
</p>
|
||||
@@ -195,8 +272,15 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
|
||||
<HourlyForecastSummary hours={upcomingHours} />
|
||||
</Card>
|
||||
<Card className="p-4 sm:p-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3>
|
||||
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<CalendarDays className="size-4 text-accent" />
|
||||
{t("forecast.daily")}
|
||||
</h3>
|
||||
<ul className="mt-2">
|
||||
{forecast.daily.map((day, index) => (
|
||||
<DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
<DayForecastCharts hours={todayHours} />
|
||||
@@ -205,7 +289,13 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
|
||||
|
||||
{forecast && <ForecastSources sources={forecast.sources} />}
|
||||
|
||||
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} />
|
||||
<DayForecastModal
|
||||
day={selectedDay}
|
||||
hours={selectedDayHours}
|
||||
locationName={locationName}
|
||||
sources={forecast?.sources ?? []}
|
||||
onClose={closeDayDetails}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,13 +13,23 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
|
||||
{t("forecast.source")}{" "}
|
||||
{hasImgw && (
|
||||
<>
|
||||
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||
<a
|
||||
href="https://meteo.imgw.pl/pogoda/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
||||
>
|
||||
IMGW ALARO <ExternalLink className="size-3" />
|
||||
</a>
|
||||
{", "}
|
||||
</>
|
||||
)}
|
||||
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||
<a
|
||||
href="https://open-meteo.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
||||
>
|
||||
Open-Meteo <ExternalLink className="size-3" />
|
||||
</a>
|
||||
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}
|
||||
|
||||
@@ -17,10 +17,14 @@ export function HydroPage() {
|
||||
const { data: stations, isPending, isError, refetch } = useHydroStations();
|
||||
const [query, setQuery] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const filteredStations = useMemo(() => (stations ?? []).filter((station) => {
|
||||
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
|
||||
return haystack.includes(query.trim().toLocaleLowerCase(locale));
|
||||
}), [locale, query, stations]);
|
||||
const filteredStations = useMemo(
|
||||
() =>
|
||||
(stations ?? []).filter((station) => {
|
||||
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
|
||||
return haystack.includes(query.trim().toLocaleLowerCase(locale));
|
||||
}),
|
||||
[locale, query, stations],
|
||||
);
|
||||
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />;
|
||||
@@ -34,13 +38,38 @@ export function HydroPage() {
|
||||
<label className="glass relative block rounded-panel p-3">
|
||||
<span className="sr-only">{t("hydro.searchLabel")}</span>
|
||||
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
|
||||
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}}
|
||||
placeholder={t("hydro.searchPlaceholder")}
|
||||
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
|
||||
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
|
||||
<p className="text-xs text-muted">
|
||||
{t("hydro.results", {
|
||||
total: filteredStations.length,
|
||||
visible: Math.min(visibleCount, filteredStations.length),
|
||||
})}
|
||||
</p>
|
||||
{!filteredStations.length ? (
|
||||
<EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
|
||||
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>{t("hydro.more")}</Button></div>}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredStations.slice(0, visibleCount).map((station, index) => (
|
||||
<HydroStationCard key={station.id} station={station} index={index} />
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < filteredStations.length && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>
|
||||
{t("hydro.more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,20 +12,38 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
return (
|
||||
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}
|
||||
>
|
||||
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
|
||||
<div>
|
||||
<div>
|
||||
<h2 className="font-semibold tracking-tight">{station.name}</h2>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-muted"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
|
||||
<MapPin className="size-3" />
|
||||
{station.river ?? t("hydro.riverUnavailable")}
|
||||
{station.province ? ` · ${station.province}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} />
|
||||
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language, temperatureUnit)} />
|
||||
<HydroMetric
|
||||
icon={Droplets}
|
||||
label={t("hydro.level")}
|
||||
value={formatWaterLevel(station.waterLevel, language)}
|
||||
/>
|
||||
<HydroMetric
|
||||
icon={Thermometer}
|
||||
label={t("hydro.water")}
|
||||
value={formatTemperature(station.waterTemperature, language, temperatureUnit)}
|
||||
/>
|
||||
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
||||
</div>
|
||||
<p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
||||
<p className="mt-4 text-[0.7rem] text-muted">
|
||||
{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.article>
|
||||
);
|
||||
@@ -34,8 +52,13 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
||||
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-card bg-surface-muted/60 p-2.5">
|
||||
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
|
||||
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
||||
<p className="flex items-center gap-1 text-[0.65rem] text-muted">
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,14 +28,24 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="min-h-screen overflow-x-hidden bg-background">
|
||||
<header className="app-header-safe sticky top-0 z-40 border-b border-border/70 bg-surface">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
wtr<span className="text-accent">.</span>
|
||||
</Link>
|
||||
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
|
||||
{visibleNavItems.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className={cn("rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70")}>
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||
active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70",
|
||||
)}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
@@ -47,12 +57,22 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
|
||||
<nav aria-label={t("nav.mobile")} className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden">
|
||||
<nav
|
||||
aria-label={t("nav.mobile")}
|
||||
className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden"
|
||||
>
|
||||
{visibleNavItems.map((item) => {
|
||||
const Icon = iconsByHref[item.href] ?? CloudSun;
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className={cn("flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||
active ? "bg-foreground text-background" : "text-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span className="max-w-full truncate">{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, BellRing, ChevronDown, Clock3, Languages, LayoutDashboard, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
MapPinned,
|
||||
Palette,
|
||||
Settings,
|
||||
Smartphone,
|
||||
Thermometer,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -14,13 +27,26 @@ import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
||||
import {
|
||||
decodeBase64UrlKey,
|
||||
deletePushSubscription,
|
||||
fetchVapidPublicKey,
|
||||
savePushSubscription,
|
||||
sendTestPushNotification,
|
||||
} from "@/lib/notification-api";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||
type NotificationSupportStatus =
|
||||
| "checking"
|
||||
| "unconfigured"
|
||||
| "unsupported"
|
||||
| "needs-install"
|
||||
| "default"
|
||||
| "denied"
|
||||
| "granted";
|
||||
|
||||
function isStandaloneApp() {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -38,12 +64,23 @@ function isAppleMobilePlatform() {
|
||||
|
||||
function getNotificationSupportStatus(): NotificationSupportStatus {
|
||||
if (typeof window === "undefined") return "checking";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
|
||||
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window))
|
||||
return "unsupported";
|
||||
if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install";
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
function SettingsRow({ icon: Icon, title, description, children }: { icon: typeof Settings; title: string; description: string; children: React.ReactNode }) {
|
||||
function SettingsRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Settings;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -64,24 +101,36 @@ function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: b
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
|
||||
checked
|
||||
? "border-accent/55 bg-accent"
|
||||
: "border-border/70 bg-surface-muted"
|
||||
checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
|
||||
} ${disabled ? "opacity-55" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`} />
|
||||
<span
|
||||
className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSwitchLabel({ icon: Icon, title, description, checked, disabled, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
|
||||
function NotificationSwitchLabel({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
|
||||
checked
|
||||
? "border-accent/45 bg-accent/10"
|
||||
: "border-border/60 bg-surface"
|
||||
checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
|
||||
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
|
||||
>
|
||||
<input
|
||||
@@ -103,7 +152,10 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa
|
||||
);
|
||||
}
|
||||
|
||||
const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"> = {
|
||||
const windSpeedUnitKeys: Record<
|
||||
WindSpeedUnit,
|
||||
"settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"
|
||||
> = {
|
||||
ms: "settings.units.wind.ms",
|
||||
kmh: "settings.units.wind.kmh",
|
||||
mph: "settings.units.wind.mph",
|
||||
@@ -145,25 +197,37 @@ export function SettingsPage() {
|
||||
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
|
||||
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
|
||||
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
||||
?? stations?.[0];
|
||||
const selectedStation =
|
||||
stations?.find((station) => station.id === selectedStationId) ??
|
||||
stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
|
||||
stations?.[0];
|
||||
const activeLocation = selectedLocation;
|
||||
const selectedRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude)
|
||||
? activeLocation?.latitude
|
||||
: (stationPosition?.latitude ?? null);
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
|
||||
? activeLocation?.longitude
|
||||
: (stationPosition?.longitude ?? null);
|
||||
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
||||
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
|
||||
const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null;
|
||||
const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null;
|
||||
const effectiveProvince = selectedRegion === "PL" ? provinceMode === "manual" ? manualProvince : selectedProvince : null;
|
||||
const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
|
||||
const selectedProvince =
|
||||
selectedRegion === "PL"
|
||||
? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID)
|
||||
: null;
|
||||
const effectiveProvince =
|
||||
selectedRegion === "PL" ? (provinceMode === "manual" ? manualProvince : selectedProvince) : null;
|
||||
const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince);
|
||||
const effectiveProvinceLabel = selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
const effectiveProvinceLabel =
|
||||
selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince
|
||||
? formatProvinceName(effectiveProvince, language)
|
||||
: t("settings.notifications.noProvince");
|
||||
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,7 +275,24 @@ export function SettingsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canUseOfficialWarnings, effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, notificationTimezone, selectedRegion, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
||||
}, [
|
||||
canUseOfficialWarnings,
|
||||
effectiveProvince,
|
||||
language,
|
||||
morningBriefEnabled,
|
||||
notificationCountyTeryt,
|
||||
notificationLatitude,
|
||||
notificationLocationName,
|
||||
notificationLongitude,
|
||||
notificationStatus,
|
||||
notificationsEnabled,
|
||||
notificationTimezone,
|
||||
selectedRegion,
|
||||
temperatureUnit,
|
||||
tomorrowBriefEnabled,
|
||||
vapidPublicKey,
|
||||
windSpeedUnit,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provinceDropdownOpen) return;
|
||||
@@ -243,11 +324,18 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [notificationStatus, t]);
|
||||
|
||||
const canUsePush = Boolean(vapidPublicKey && (selectedRegion === "GLOBAL" || effectiveProvince) && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
|
||||
const canUsePush = Boolean(
|
||||
vapidPublicKey &&
|
||||
(selectedRegion === "GLOBAL" || effectiveProvince) &&
|
||||
notificationStatus !== "unsupported" &&
|
||||
notificationStatus !== "needs-install" &&
|
||||
notificationStatus !== "denied" &&
|
||||
notificationStatus !== "unconfigured",
|
||||
);
|
||||
|
||||
async function getServiceWorkerRegistration() {
|
||||
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
||||
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js");
|
||||
return (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register("/sw.js"));
|
||||
}
|
||||
|
||||
async function enableWarningNotifications() {
|
||||
@@ -270,10 +358,12 @@ export function SettingsPage() {
|
||||
}
|
||||
const registration = await getServiceWorkerRegistration();
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
const subscription =
|
||||
existingSubscription ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
}));
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings,
|
||||
@@ -387,20 +477,35 @@ export function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<p className="section-kicker flex items-center gap-2"><Settings className="size-4" />{t("settings.section")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<Settings className="size-4" />
|
||||
{t("settings.section")}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
|
||||
</section>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<SettingsRow icon={Languages} title={t("settings.language.title")} description={t("settings.language.description")}>
|
||||
<SettingsRow
|
||||
icon={Languages}
|
||||
title={t("settings.language.title")}
|
||||
description={t("settings.language.description")}
|
||||
>
|
||||
<LanguageToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
||||
<ThemeToggle />
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Thermometer} title={t("settings.units.temperature.title")} description={t("settings.units.temperature.description")}>
|
||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}>
|
||||
<SettingsRow
|
||||
icon={Thermometer}
|
||||
title={t("settings.units.temperature.title")}
|
||||
description={t("settings.units.temperature.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.temperature.label")}
|
||||
>
|
||||
{TEMPERATURE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
@@ -419,8 +524,16 @@ export function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
|
||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
|
||||
<SettingsRow
|
||||
icon={Wind}
|
||||
title={t("settings.units.wind.title")}
|
||||
description={t("settings.units.wind.description")}
|
||||
>
|
||||
<div
|
||||
className="surface-control inline-flex rounded-full p-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.units.wind.label")}
|
||||
>
|
||||
{WIND_SPEED_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
@@ -509,7 +622,9 @@ export function SettingsPage() {
|
||||
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">
|
||||
{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,7 +640,11 @@ export function SettingsPage() {
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{t("settings.notifications.regionSelected")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedProvince ? formatProvinceName(selectedProvince, language) : t("settings.notifications.noProvince")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{selectedProvince
|
||||
? formatProvinceName(selectedProvince, language)
|
||||
: t("settings.notifications.noProvince")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
@@ -550,7 +669,8 @@ export function SettingsPage() {
|
||||
aria-expanded={provinceDropdownOpen}
|
||||
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false);
|
||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
|
||||
setProvinceDropdownOpen(false);
|
||||
}}
|
||||
onClick={() => setProvinceDropdownOpen((current) => !current)}
|
||||
className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60"
|
||||
@@ -581,7 +701,9 @@ export function SettingsPage() {
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{formatProvinceName(province, language)}</span>
|
||||
{manualProvinceValue === province && <span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />}
|
||||
{manualProvinceValue === province && (
|
||||
<span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -621,9 +743,15 @@ export function SettingsPage() {
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Bell className="size-3.5 text-accent" aria-hidden="true" />
|
||||
{selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : t("settings.notifications.enable")}
|
||||
{selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.enableDevice")
|
||||
: t("settings.notifications.enable")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.enableGlobalDescription")
|
||||
: t("settings.notifications.enableDescription")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedRegion === "GLOBAL" ? t("settings.notifications.enableGlobalDescription") : t("settings.notifications.enableDescription")}</span>
|
||||
<span className="mt-2 block text-xs font-semibold text-accent">
|
||||
{isSavingSubscription
|
||||
? t("settings.notifications.saving")
|
||||
@@ -632,7 +760,10 @@ export function SettingsPage() {
|
||||
: t("settings.notifications.disabledStatus")}
|
||||
</span>
|
||||
</span>
|
||||
<SwitchIndicator checked={notificationsEnabled} disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} />
|
||||
<SwitchIndicator
|
||||
checked={notificationsEnabled}
|
||||
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
|
||||
/>
|
||||
</button>
|
||||
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||
|
||||
@@ -655,9 +786,16 @@ export function SettingsPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
|
||||
onClick={sendTestNotification}
|
||||
>
|
||||
<BellRing className="size-4" />
|
||||
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")}
|
||||
{isSendingTestNotification
|
||||
? t("settings.notifications.sendingTest")
|
||||
: t("settings.notifications.sendTest")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,10 +2,20 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { CircleCheckBig } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = CircleCheckBig,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent"><Icon className="size-6" /></div>
|
||||
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
|
||||
</Card>
|
||||
|
||||
@@ -5,14 +5,27 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function ErrorState({ title, description, onRetry }: { title?: string; description?: string; onRetry: () => void }) {
|
||||
export function ErrorState({
|
||||
title,
|
||||
description,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning"><TriangleAlert className="size-6" /></div>
|
||||
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning">
|
||||
<TriangleAlert className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
|
||||
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p>
|
||||
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||
<Button variant="glass" onClick={onRetry}>
|
||||
<RefreshCw className="size-4" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function LoadingSkeleton({ className = "" }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
return <div className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)} aria-label={t("common.loading")} />;
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)}
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLoadingSkeleton() {
|
||||
@@ -13,7 +18,9 @@ export function PageLoadingSkeleton() {
|
||||
<div className="space-y-5" aria-busy="true">
|
||||
<LoadingSkeleton className="h-[25rem]" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => <LoadingSkeleton className="h-36" key={index} />)}
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<LoadingSkeleton className="h-36" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,17 +53,25 @@ export function DashboardWarnings() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null;
|
||||
const province = isGlobalLocation
|
||||
? null
|
||||
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const regionLabel = selectedLocation?.countyTeryt
|
||||
? (selectedLocation.district ?? selectedLocation.name)
|
||||
: province
|
||||
? formatProvinceName(province, language)
|
||||
: null;
|
||||
const relevantWarnings = useMemo(() => {
|
||||
if (isGlobalLocation || !warnings || !province || now === null) return [];
|
||||
|
||||
return warnings
|
||||
.filter((warning) => {
|
||||
const validTo = getTimestamp(warning.validTo);
|
||||
return warning.kind === "meteo"
|
||||
&& warningMatchesLocalSelection(warning, province, selectedLocation)
|
||||
&& (validTo === null || validTo > now);
|
||||
return (
|
||||
warning.kind === "meteo" &&
|
||||
warningMatchesLocalSelection(warning, province, selectedLocation) &&
|
||||
(validTo === null || validTo > now)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => compareDashboardWarnings(a, b, now));
|
||||
}, [isGlobalLocation, now, province, selectedLocation, warnings]);
|
||||
@@ -87,12 +95,8 @@ export function DashboardWarnings() {
|
||||
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
|
||||
IMGW · {regionLabel}
|
||||
</p>
|
||||
<h2 className="mt-1 text-base font-semibold text-foreground">
|
||||
{t("warnings.dashboard.title")}
|
||||
</h2>
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">IMGW · {regionLabel}</p>
|
||||
<h2 className="mt-1 text-base font-semibold text-foreground">{t("warnings.dashboard.title")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
@@ -109,13 +113,11 @@ export function DashboardWarnings() {
|
||||
const active = isWarningActive(warning, now);
|
||||
const validityLabel = active
|
||||
? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) })
|
||||
: warning.validFrom && t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
|
||||
: warning.validFrom &&
|
||||
t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
|
||||
|
||||
return (
|
||||
<article
|
||||
key={warning.id}
|
||||
className="rounded-card border border-warning/20 bg-surface px-3.5 py-3"
|
||||
>
|
||||
<article key={warning.id} className="rounded-card border border-warning/20 bg-surface px-3.5 py-3">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
|
||||
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
|
||||
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
|
||||
|
||||
@@ -12,25 +12,57 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
|
||||
const { language, t } = useI18n();
|
||||
const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
|
||||
const level = warning.level;
|
||||
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
|
||||
const areasLabel = warning.areas.length > 8
|
||||
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
|
||||
: warning.areas.join("; ");
|
||||
const levelLabel =
|
||||
level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
|
||||
const areasLabel =
|
||||
warning.areas.length > 8
|
||||
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
|
||||
: warning.areas.join("; ");
|
||||
return (
|
||||
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}
|
||||
>
|
||||
<Card className="h-full overflow-hidden p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
|
||||
<span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span>
|
||||
<div className="rounded-card bg-warning/10 p-2.5 text-warning">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-control border px-2.5 py-1 text-xs font-semibold",
|
||||
level === -1
|
||||
? "border-warning/30 bg-warning/10 text-warning"
|
||||
: "border-warning/25 bg-warning/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{levelLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
|
||||
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">
|
||||
{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">
|
||||
{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}
|
||||
</h2>
|
||||
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
|
||||
<div className="mt-5 space-y-2 text-xs text-muted">
|
||||
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} — {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
|
||||
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
|
||||
<p className="flex items-start gap-2">
|
||||
<CalendarClock className="mt-0.5 size-3.5 shrink-0" />
|
||||
{formatDateTime(warning.validFrom, language)} —{" "}
|
||||
{warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}
|
||||
</p>
|
||||
<p className="flex items-start gap-2">
|
||||
<MapPinned className="mt-0.5 size-3.5 shrink-0" />
|
||||
{areasLabel || t("warnings.areaUnknown")}
|
||||
</p>
|
||||
</div>
|
||||
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-muted">{t("warnings.probability", { value: warning.probability })}</p>}
|
||||
{warning.probability !== null && (
|
||||
<p className="mt-4 text-xs font-medium text-muted">
|
||||
{t("warnings.probability", { value: warning.probability })}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.article>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,9 @@ import type { WeatherWarning } from "@/types/imgw";
|
||||
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{warnings.map((warning, index) => <WarningCard key={warning.id} warning={warning} index={index + indexOffset} />)}
|
||||
{warnings.map((warning, index) => (
|
||||
<WarningCard key={warning.id} warning={warning} index={index + indexOffset} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,37 +29,60 @@ export function WarningsPanel() {
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
if (selectedLocation?.region === "GLOBAL") {
|
||||
return <EmptyState title={t("warnings.globalUnavailableTitle")} description={t("warnings.globalUnavailableDescription")} />;
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("warnings.globalUnavailableTitle")}
|
||||
description={t("warnings.globalUnavailableDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
||||
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
||||
if (!warnings?.length)
|
||||
return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
||||
|
||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
if (!province) return <WarningGrid warnings={warnings} />;
|
||||
|
||||
const provinceLabel = formatProvinceName(province, language);
|
||||
const localLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : provinceLabel;
|
||||
const localLabel = selectedLocation?.countyTeryt
|
||||
? (selectedLocation.district ?? selectedLocation.name)
|
||||
: provinceLabel;
|
||||
const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation));
|
||||
const otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation));
|
||||
const otherWarnings = warnings.filter(
|
||||
(warning) => !warningMatchesLocalSelection(warning, province, selectedLocation),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-9">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p>
|
||||
<p className="section-kicker flex items-center gap-2">
|
||||
<MapPinned className="size-4" />
|
||||
{t("warnings.myProvince")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{localLabel}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: localLabel })}</p>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
{t("warnings.myProvinceDescription", { province: localLabel })}
|
||||
</p>
|
||||
</div>
|
||||
{localWarnings.length
|
||||
? <WarningGrid warnings={localWarnings} />
|
||||
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />}
|
||||
{localWarnings.length ? (
|
||||
<WarningGrid warnings={localWarnings} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t("warnings.myProvinceEmptyTitle")}
|
||||
description={t("warnings.myProvinceEmptyDescription", { province: localLabel })}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{otherWarnings.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
<Map className="size-4" />
|
||||
{t("warnings.otherRegions")}
|
||||
</p>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
|
||||
</div>
|
||||
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user