Files
Termix/src/ui/features/homepage/widgets/WeatherWidget.tsx
T
ZacharyZcR 867058bddd perf: frontend request cache, poll pause, and code-split shell (#1052)
Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists.
2026-07-14 13:58:28 +08:00

202 lines
6.3 KiB
TypeScript

import { useEffect, useState } from "react";
import {
Cloud,
CloudRain,
Sun,
CloudSnow,
Wind,
Thermometer,
} from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
WeatherConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
interface WttrCurrent {
temp_C: string;
FeelsLikeC: string;
weatherDesc: { value: string }[];
weatherCode: string;
}
interface WttrDay {
mintempC: string;
maxtempC: string;
hourly: { weatherDesc: { value: string }[]; weatherCode: string }[];
}
interface WttrResponse {
nearest_area: {
areaName: { value: string }[];
country: { value: string }[];
}[];
current_condition: WttrCurrent[];
weather: WttrDay[];
}
function weatherIcon(code: string, size = 20) {
const n = Number(code);
if (n === 113) return <Sun size={size} className="text-yellow-400" />;
if (n <= 119) return <Cloud size={size} className="text-gray-400" />;
if (n <= 143) return <Cloud size={size} className="text-gray-400" />;
if (n <= 266) return <CloudRain size={size} className="text-blue-400" />;
if (n <= 335) return <CloudSnow size={size} className="text-blue-200" />;
if (n <= 389) return <CloudRain size={size} className="text-blue-500" />;
return <Wind size={size} className="text-muted-foreground" />;
}
function celsiusToF(c: number) {
return Math.round((c * 9) / 5 + 32);
}
function WeatherWidget({
widget,
config,
}: WidgetComponentProps<WeatherConfig>) {
const { location, unit, showForecast } = config;
const [data, setData] = useState<WttrResponse | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
if (!location) return;
let cancelled = false;
const fetchWeather = async () => {
try {
setError(false);
const res = await fetch(
`https://wttr.in/${encodeURIComponent(location)}?format=j1`,
);
if (!res.ok) throw new Error("bad response");
const json: WttrResponse = await res.json();
if (!cancelled) setData(json);
} catch {
if (!cancelled) setError(true);
}
};
fetchWeather();
const stop = runVisibleInterval(
() => {
void fetchWeather();
},
10 * 60 * 1000,
);
return () => {
cancelled = true;
stop();
};
}, [location]);
if (!location) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60 p-3 text-center">
Configure a location in widget settings
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
Could not load weather for "{location}"
</div>
);
}
if (!data) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
Loading...
</div>
);
}
const current = data.current_condition[0];
const area = data.nearest_area[0];
const cityName = area?.areaName[0]?.value ?? location;
const tempC = Number(current.temp_C);
const feelsC = Number(current.FeelsLikeC);
const displayTemp = unit === "F" ? `${celsiusToF(tempC)}°F` : `${tempC}°C`;
const displayFeels = unit === "F" ? `${celsiusToF(feelsC)}°F` : `${feelsC}°C`;
const desc = current.weatherDesc[0]?.value ?? "";
const code = current.weatherCode;
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Sun size={11} />} />
<div className="flex flex-col flex-1 p-3 gap-2 overflow-auto">
{/* Current conditions */}
<div className="flex items-center gap-3 flex-1 min-h-0">
<div className="shrink-0">{weatherIcon(code, 32)}</div>
<div className="flex flex-col min-w-0">
<span className="text-2xl font-bold text-foreground leading-none">
{displayTemp}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{desc}
</span>
<span className="text-[10px] text-muted-foreground/70">
Feels like {displayFeels}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Thermometer
size={10}
className="text-muted-foreground/60 shrink-0"
/>
<span className="text-[11px] font-semibold text-foreground truncate">
{cityName}
</span>
</div>
{/* 3-day forecast */}
{showForecast && data.weather.length >= 3 && (
<div className="flex gap-1 border-t border-border pt-2 mt-auto shrink-0">
{data.weather.slice(0, 3).map((day, i) => {
const hourly = day.hourly[4] ?? day.hourly[0];
const minC = Number(day.mintempC);
const maxC = Number(day.maxtempC);
const lo = unit === "F" ? `${celsiusToF(minC)}°` : `${minC}°`;
const hi = unit === "F" ? `${celsiusToF(maxC)}°` : `${maxC}°`;
const dayLabel = i === 0 ? "Today" : i === 1 ? "Tmrw" : "+2";
return (
<div
key={i}
className="flex-1 flex flex-col items-center gap-0.5"
>
<span className="text-[9px] text-muted-foreground/70">
{dayLabel}
</span>
{weatherIcon(hourly?.weatherCode ?? "113", 12)}
<span className="text-[9px] text-foreground">{hi}</span>
<span className="text-[9px] text-muted-foreground/60">
{lo}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
registerWidget<WeatherConfig>({
id: "weather",
name: "Weather",
description: "Live weather for any location",
category: "info",
icon: <Sun size={14} />,
defaultConfig: { location: "", unit: "C", showForecast: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: WeatherWidget,
});
export { WeatherWidget };