feat: add interactive daily forecast details
This commit is contained in:
74
components/charts/day-forecast-charts.tsx
Normal file
74
components/charts/day-forecast-charts.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
||||
import type { HourlyForecast } from "@/types/forecast";
|
||||
|
||||
function formatHour(value: string) {
|
||||
return value.slice(11, 16);
|
||||
}
|
||||
|
||||
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||
const { language, t } = useI18n();
|
||||
const rows = hours.map((hour) => ({
|
||||
time: formatHour(hour.time),
|
||||
temperature: hour.temperature,
|
||||
feelsLike: hour.feelsLike,
|
||||
precipitation: hour.precipitation,
|
||||
precipitationProbability: hour.precipitationProbability,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Card className="p-4 sm:p-5">
|
||||
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.temperatureChartDescription")}</p>
|
||||
<div className="mt-4 h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
||||
<CartesianGrid stroke="rgba(148,163,184,0.18)" 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="°" />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }}
|
||||
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke="#0284c7" strokeWidth={3} dot={false} connectNulls />
|
||||
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke="#818cf8" strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p>
|
||||
<div className="mt-4 h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
||||
<CartesianGrid stroke="rgba(148,163,184,0.18)" 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="%" />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }}
|
||||
formatter={(value, name) => [
|
||||
name === t("forecast.precipitation")
|
||||
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
||||
: `${typeof value === "number" ? value : "—"}%`,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill="#38bdf8" radius={[5, 5, 0, 0]} />
|
||||
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke="#6366f1" strokeWidth={2} dot={false} connectNulls />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
components/forecast/day-forecast-modal.tsx
Normal file
184
components/forecast/day-forecast-modal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CloudSun, Droplets, ExternalLink, Sunrise, Sunset, Wind, X } from "lucide-react";
|
||||
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
formatForecastRainfall,
|
||||
formatForecastTemperature,
|
||||
formatForecastWind,
|
||||
getForecastCondition,
|
||||
isForecastHourPast,
|
||||
} from "@/lib/forecast-utils";
|
||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||
|
||||
function formatHour(value: string | null) {
|
||||
if (!value) return "—";
|
||||
return value.slice(11, 16);
|
||||
}
|
||||
|
||||
function getMaximumWind(hours: HourlyForecast[]) {
|
||||
return hours.reduce<number | null>((maximum, hour) => {
|
||||
if (hour.windSpeed === null) return maximum;
|
||||
return maximum === null ? hour.windSpeed : Math.max(maximum, hour.windSpeed);
|
||||
}, null);
|
||||
}
|
||||
|
||||
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
|
||||
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-base font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayForecastModal({
|
||||
day,
|
||||
hours,
|
||||
locationName,
|
||||
onClose,
|
||||
}: {
|
||||
day: DailyForecast | null;
|
||||
hours: HourlyForecast[];
|
||||
locationName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { language, locale, t } = useI18n();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!day) return;
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [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`))
|
||||
: "";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{day ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/55 p-0 backdrop-blur-md sm:p-4 lg:p-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onPointerDown={onClose}
|
||||
>
|
||||
<motion.section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="day-forecast-title"
|
||||
className="weather-scrollbar h-full w-full overflow-y-auto bg-gradient-to-b from-sky-100/95 via-slate-100/95 to-white/95 shadow-2xl dark:from-slate-900/95 dark:via-slate-950/95 dark:to-slate-950/95 sm:max-w-6xl sm:rounded-[2rem] sm:border sm:border-white/30 dark:sm:border-white/10"
|
||||
initial={{ opacity: 0, y: 28, scale: 0.985 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">
|
||||
<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>
|
||||
<p className="mt-1 capitalize text-slate-600 dark:text-slate-300">{formattedDate}</p>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
aria-label={t("forecast.closeDetails")}
|
||||
className="rounded-full border border-white/35 bg-white/35 p-3 text-slate-700 transition hover:bg-white/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-100 dark:hover:bg-white/20"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden bg-gradient-to-br from-sky-500/20 via-white/25 to-indigo-400/15 p-5 dark:from-sky-700/20 dark:via-white/5 dark:to-indigo-500/15 sm:p-6">
|
||||
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)}</p>
|
||||
<div className="mt-2 flex items-end gap-4">
|
||||
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-sky-700 dark:text-sky-300" />
|
||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
||||
<p className="mb-2 text-2xl text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</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)} />
|
||||
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
|
||||
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden p-4 sm:p-5">
|
||||
<h3 className="text-sm font-semibold">{t("forecast.hourlyForDay")}</h3>
|
||||
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5">
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{hours.map((hour) => {
|
||||
const isPast = isForecastHourPast(hour.time);
|
||||
return (
|
||||
<li
|
||||
key={hour.time}
|
||||
className={cn(
|
||||
"w-[5.2rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5",
|
||||
isPast && "opacity-45",
|
||||
)}
|
||||
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
|
||||
>
|
||||
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
|
||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
|
||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300">
|
||||
<Droplets className="size-3" />
|
||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<DayForecastCharts hours={hours} />
|
||||
|
||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
||||
{t("forecast.source")}{" "}
|
||||
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">
|
||||
Open-Meteo <ExternalLink className="size-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarDays, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { CalendarDays, ChevronRight, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
@@ -9,7 +11,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useForecast } from "@/hooks/use-forecast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { formatForecastRainfall, formatForecastTemperature, getForecastCondition } from "@/lib/forecast-utils";
|
||||
import {
|
||||
formatForecastRainfall,
|
||||
formatForecastTemperature,
|
||||
getForecastCondition,
|
||||
getHourlyForecastForDay,
|
||||
getUpcomingHourlyForecast,
|
||||
} from "@/lib/forecast-utils";
|
||||
import type { DailyForecast } from "@/types/forecast";
|
||||
|
||||
function formatHour(value: string) {
|
||||
@@ -21,22 +29,32 @@ function formatDay(value: string, locale: string, todayLabel: string, index: num
|
||||
return new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(new Date(`${value}T12:00:00Z`));
|
||||
}
|
||||
|
||||
function DailyForecastRow({ day, index }: { day: DailyForecast; index: number }) {
|
||||
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
|
||||
const { language, locale, t } = useI18n();
|
||||
const label = formatDay(day.date, locale, t("forecast.today"), index);
|
||||
return (
|
||||
<motion.li
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
|
||||
className="grid grid-cols-[4.5rem_1fr_auto] items-center gap-2 border-t border-white/30 py-3 first:border-t-0 dark:border-white/10 sm:grid-cols-[5rem_1fr_5rem_auto]"
|
||||
className="border-t border-white/30 first:border-t-0 dark:border-white/10"
|
||||
>
|
||||
<p className="text-sm font-semibold capitalize">{formatDay(day.date, locale, t("forecast.today"), index)}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
|
||||
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||
</div>
|
||||
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.99 }}
|
||||
aria-label={t("forecast.openDayDetails", { day: label })}
|
||||
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-xl px-1 py-3 text-left transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:hover:bg-white/5 sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
|
||||
onClick={() => onSelect(day)}
|
||||
>
|
||||
<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-sky-600 dark:text-sky-300" />
|
||||
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||
</div>
|
||||
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
||||
<ChevronRight className="hidden size-4 text-slate-400 sm:block" />
|
||||
</motion.button>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +62,10 @@ function DailyForecastRow({ day, index }: { day: DailyForecast; index: number })
|
||||
export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
||||
const { language, t } = useI18n();
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
||||
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
|
||||
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
|
||||
const selectedDayHours = forecast && selectedDay ? getHourlyForecastForDay(forecast.hourly, selectedDay.date) : [];
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
@@ -71,7 +93,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-sky-600 dark:text-sky-300" />{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">
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{forecast.hourly.map((hour, index) => (
|
||||
{upcomingHours.map((hour, index) => (
|
||||
<motion.li
|
||||
key={hour.time}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -91,7 +113,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
</Card>
|
||||
<Card className="p-4 sm:p-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3>
|
||||
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} />)}</ul>
|
||||
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -99,6 +121,8 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
||||
{t("forecast.source")} <a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo <ExternalLink className="size-3" /></a>
|
||||
</p>
|
||||
|
||||
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} onClose={closeDayDetails} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user