187 lines
8.2 KiB
TypeScript
187 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { CloudSun, Droplets, Sunrise, Sunset, Wind, X } from "lucide-react";
|
|
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
|
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
|
import { ForecastSources } from "@/components/forecast/forecast-sources";
|
|
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, ForecastSource, 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-card border border-border/60 bg-surface-muted/55 p-3">
|
|
<Icon className="size-4 text-accent" />
|
|
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{label}</p>
|
|
<p className="mt-1 text-base font-semibold">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DayForecastModal({
|
|
day,
|
|
hours,
|
|
locationName,
|
|
sources,
|
|
onClose,
|
|
}: {
|
|
day: DailyForecast | null;
|
|
hours: HourlyForecast[];
|
|
locationName: string;
|
|
sources: ForecastSource[];
|
|
onClose: () => void;
|
|
}) {
|
|
const { language, locale, t } = useI18n();
|
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
const portalRoot = typeof document === "undefined" ? null : document.body;
|
|
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`))
|
|
: "";
|
|
|
|
const modal = (
|
|
<AnimatePresence>
|
|
{day ? (
|
|
<motion.div
|
|
className="modal-overlay z-[90] flex items-center justify-center p-0 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-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70"
|
|
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="section-kicker flex items-center gap-2">
|
|
<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-muted">{formattedDate}</p>
|
|
</div>
|
|
<button
|
|
ref={closeButtonRef}
|
|
type="button"
|
|
aria-label={t("forecast.closeDetails")}
|
|
className="surface-control rounded-control p-3 text-foreground transition hover:bg-surface-raised/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
|
onClick={onClose}
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<Card className="overflow-hidden bg-surface-raised/70 p-5 sm:p-6">
|
|
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
|
<div>
|
|
<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)}</p>
|
|
<p className="mb-2 text-2xl text-muted">{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-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center",
|
|
isPast && "opacity-45",
|
|
)}
|
|
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
|
|
>
|
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
|
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
|
<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-accent">
|
|
<Droplets className="size-3" />
|
|
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
|
</p>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</Card>
|
|
|
|
<DayForecastCharts hours={hours} />
|
|
|
|
<ForecastSources sources={sources} />
|
|
</div>
|
|
</motion.section>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
return portalRoot ? createPortal(modal, portalRoot) : null;
|
|
}
|