feat(app): add initial widgets

This commit is contained in:
ItzCrazyKns
2025-11-23 19:46:42 +05:30
parent d5f62f2dca
commit 4e7143ce0c
10 changed files with 1703 additions and 80 deletions

View File

@@ -91,7 +91,7 @@ const WeatherWidget = () => {
setData({
temperature: data.temperature,
condition: data.condition,
location: location.city,
location: 'Mars',
humidity: data.humidity,
windSpeed: data.windSpeed,
icon: data.icon,

View File

@@ -0,0 +1,54 @@
'use client';
import { Calculator, Equal } from 'lucide-react';
type CalculationWidgetProps = {
expression: string;
result: number;
};
const Calculation = ({ expression, result }: CalculationWidgetProps) => {
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 p-3 bg-light-100/50 dark:bg-dark-100/50 border-b border-light-200 dark:border-dark-200">
<div className="rounded-full p-1.5 bg-light-100 dark:bg-dark-100">
<Calculator className="w-4 h-4 text-black/70 dark:text-white/70" />
</div>
<span className="text-sm font-medium text-black dark:text-white">
Calculation
</span>
</div>
<div className="p-4 space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-xs text-black/50 dark:text-white/50 font-medium">
Expression
</span>
</div>
<div className="bg-light-100 dark:bg-dark-100 rounded-md p-2.5 border border-light-200 dark:border-dark-200">
<code className="text-sm text-black dark:text-white font-mono break-all">
{expression}
</code>
</div>
</div>
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Equal className="w-3.5 h-3.5 text-black/50 dark:text-white/50" />
<span className="text-xs text-black/50 dark:text-white/50 font-medium">
Result
</span>
</div>
<div className="bg-gradient-to-br from-light-100 to-light-secondary dark:from-dark-100 dark:to-dark-secondary rounded-md p-4 border-2 border-light-200 dark:border-dark-200">
<div className="text-4xl font-bold text-black dark:text-white font-mono tabular-nums">
{result.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
);
};
export default Calculation;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Widget } from '../ChatWindow';
import Weather from './Weather';
import Calculation from './Calculation';
import Stock from './Stock';
const Renderer = ({ widgets }: { widgets: Widget[] }) => {
return widgets.map((widget, index) => {
switch (widget.widgetType) {
case 'weather':
return (
<Weather
key={index}
location={widget.params.location}
current={widget.params.current}
daily={widget.params.daily}
timezone={widget.params.timezone}
/>
);
case 'calculation_result':
return (
<Calculation
expression={widget.params.expression}
result={widget.params.result}
key={index}
/>
);
case 'stock':
return (
<Stock
key={index}
symbol={widget.params.symbol}
shortName={widget.params.shortName}
longName={widget.params.longName}
exchange={widget.params.exchange}
currency={widget.params.currency}
marketState={widget.params.marketState}
regularMarketPrice={widget.params.regularMarketPrice}
regularMarketChange={widget.params.regularMarketChange}
regularMarketChangePercent={
widget.params.regularMarketChangePercent
}
regularMarketPreviousClose={
widget.params.regularMarketPreviousClose
}
regularMarketOpen={widget.params.regularMarketOpen}
regularMarketDayHigh={widget.params.regularMarketDayHigh}
regularMarketDayLow={widget.params.regularMarketDayLow}
regularMarketVolume={widget.params.regularMarketVolume}
averageDailyVolume3Month={widget.params.averageDailyVolume3Month}
marketCap={widget.params.marketCap}
fiftyTwoWeekLow={widget.params.fiftyTwoWeekLow}
fiftyTwoWeekHigh={widget.params.fiftyTwoWeekHigh}
trailingPE={widget.params.trailingPE}
forwardPE={widget.params.forwardPE}
dividendYield={widget.params.dividendYield}
earningsPerShare={widget.params.earningsPerShare}
website={widget.params.website}
postMarketPrice={widget.params.postMarketPrice}
postMarketChange={widget.params.postMarketChange}
postMarketChangePercent={widget.params.postMarketChangePercent}
preMarketPrice={widget.params.preMarketPrice}
preMarketChange={widget.params.preMarketChange}
preMarketChangePercent={widget.params.preMarketChangePercent}
chartData={widget.params.chartData}
comparisonData={widget.params.comparisonData}
error={widget.params.error}
/>
);
default:
return <div key={index}>Unknown widget type: {widget.widgetType}</div>;
}
});
};
export default Renderer;

View File

@@ -0,0 +1,517 @@
'use client';
import { Clock, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
createChart,
ColorType,
LineStyle,
BaselineSeries,
LineSeries,
} from 'lightweight-charts';
type StockWidgetProps = {
symbol: string;
shortName: string;
longName?: string;
exchange?: string;
currency?: string;
marketState?: string;
regularMarketPrice?: number;
regularMarketChange?: number;
regularMarketChangePercent?: number;
regularMarketPreviousClose?: number;
regularMarketOpen?: number;
regularMarketDayHigh?: number;
regularMarketDayLow?: number;
regularMarketVolume?: number;
averageDailyVolume3Month?: number;
marketCap?: number;
fiftyTwoWeekLow?: number;
fiftyTwoWeekHigh?: number;
trailingPE?: number;
forwardPE?: number;
dividendYield?: number;
earningsPerShare?: number;
website?: string;
postMarketPrice?: number;
postMarketChange?: number;
postMarketChangePercent?: number;
preMarketPrice?: number;
preMarketChange?: number;
preMarketChangePercent?: number;
chartData?: {
'1D'?: { timestamps: number[]; prices: number[] } | null;
'5D'?: { timestamps: number[]; prices: number[] } | null;
'1M'?: { timestamps: number[]; prices: number[] } | null;
'3M'?: { timestamps: number[]; prices: number[] } | null;
'6M'?: { timestamps: number[]; prices: number[] } | null;
'1Y'?: { timestamps: number[]; prices: number[] } | null;
MAX?: { timestamps: number[]; prices: number[] } | null;
} | null;
comparisonData?: Array<{
ticker: string;
name: string;
chartData: {
'1D'?: { timestamps: number[]; prices: number[] } | null;
'5D'?: { timestamps: number[]; prices: number[] } | null;
'1M'?: { timestamps: number[]; prices: number[] } | null;
'3M'?: { timestamps: number[]; prices: number[] } | null;
'6M'?: { timestamps: number[]; prices: number[] } | null;
'1Y'?: { timestamps: number[]; prices: number[] } | null;
MAX?: { timestamps: number[]; prices: number[] } | null;
};
}> | null;
error?: string;
};
const formatNumber = (num: number | undefined, decimals = 2): string => {
if (num === undefined || num === null) return 'N/A';
return num.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
const formatLargeNumber = (num: number | undefined): string => {
if (num === undefined || num === null) return 'N/A';
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const Stock = (props: StockWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const [selectedTimeframe, setSelectedTimeframe] = useState<
'1D' | '5D' | '1M' | '3M' | '6M' | '1Y' | 'MAX'
>('1M');
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
const currentChartData = props.chartData?.[selectedTimeframe];
if (
!chartContainerRef.current ||
!currentChartData ||
currentChartData.timestamps.length === 0
) {
return;
}
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 280,
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: isDarkMode ? '#6b7280' : '#9ca3af',
fontSize: 11,
attributionLogo: false,
},
grid: {
vertLines: {
color: isDarkMode ? '#21262d' : '#e8edf1',
style: LineStyle.Solid,
},
horzLines: {
color: isDarkMode ? '#21262d' : '#e8edf1',
style: LineStyle.Solid,
},
},
crosshair: {
vertLine: {
color: isDarkMode ? '#30363d' : '#d0d7de',
labelVisible: false,
},
horzLine: {
color: isDarkMode ? '#30363d' : '#d0d7de',
labelVisible: true,
},
},
rightPriceScale: {
borderVisible: false,
visible: false,
},
leftPriceScale: {
borderVisible: false,
visible: true,
},
timeScale: {
borderVisible: false,
timeVisible: false,
},
handleScroll: false,
handleScale: false,
});
const prices = currentChartData.prices;
let baselinePrice: number;
if (selectedTimeframe === '1D') {
baselinePrice = props.regularMarketPreviousClose ?? prices[0];
} else {
baselinePrice = prices[0];
}
const baselineSeries = chart.addSeries(BaselineSeries);
baselineSeries.applyOptions({
baseValue: { type: 'price', price: baselinePrice },
topLineColor: isDarkMode ? '#14b8a6' : '#0d9488',
topFillColor1: isDarkMode
? 'rgba(20, 184, 166, 0.28)'
: 'rgba(13, 148, 136, 0.24)',
topFillColor2: isDarkMode
? 'rgba(20, 184, 166, 0.05)'
: 'rgba(13, 148, 136, 0.05)',
bottomLineColor: isDarkMode ? '#f87171' : '#dc2626',
bottomFillColor1: isDarkMode
? 'rgba(248, 113, 113, 0.05)'
: 'rgba(220, 38, 38, 0.05)',
bottomFillColor2: isDarkMode
? 'rgba(248, 113, 113, 0.28)'
: 'rgba(220, 38, 38, 0.24)',
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
crosshairMarkerBorderColor: '',
crosshairMarkerBackgroundColor: '',
});
const data = currentChartData.timestamps.map((timestamp, index) => {
const price = currentChartData.prices[index];
return {
time: (timestamp / 1000) as any,
value: price,
};
});
baselineSeries.setData(data);
const comparisonColors = ['#8b5cf6', '#f59e0b', '#ec4899'];
if (props.comparisonData && props.comparisonData.length > 0) {
props.comparisonData.forEach((comp, index) => {
const compChartData = comp.chartData[selectedTimeframe];
if (compChartData && compChartData.prices.length > 0) {
const compData = compChartData.timestamps.map((timestamp, i) => ({
time: (timestamp / 1000) as any,
value: compChartData.prices[i],
}));
const compSeries = chart.addSeries(LineSeries);
compSeries.applyOptions({
color: comparisonColors[index] || '#6b7280',
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
priceScaleId: 'left',
});
compSeries.setData(compData);
}
});
}
chart.timeScale().fitContent();
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [
props.chartData,
props.comparisonData,
selectedTimeframe,
isDarkMode,
props.regularMarketPreviousClose,
]);
const isPositive = (props.regularMarketChange ?? 0) >= 0;
const isMarketOpen = props.marketState === 'REGULAR';
const isPreMarket = props.marketState === 'PRE';
const isPostMarket = props.marketState === 'POST';
const displayPrice = isPostMarket
? props.postMarketPrice ?? props.regularMarketPrice
: isPreMarket
? props.preMarketPrice ?? props.regularMarketPrice
: props.regularMarketPrice;
const displayChange = isPostMarket
? props.postMarketChange ?? props.regularMarketChange
: isPreMarket
? props.preMarketChange ?? props.regularMarketChange
: props.regularMarketChange;
const displayChangePercent = isPostMarket
? props.postMarketChangePercent ?? props.regularMarketChangePercent
: isPreMarket
? props.preMarketChangePercent ?? props.regularMarketChangePercent
: props.regularMarketChangePercent;
const changeColor = isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
if (props.error) {
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-4">
<p className="text-sm text-black dark:text-white">
Error: {props.error}
</p>
</div>
);
}
return (
<div className="rounded-lg border border-light-200 dark:border-dark-200 overflow-hidden">
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4 pb-4 border-b border-light-200 dark:border-dark-200">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{props.website && (
<img
src={`https://logo.clearbit.com/${new URL(props.website).hostname}`}
alt={`${props.symbol} logo`}
className="w-8 h-8 rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<h3 className="text-2xl font-bold text-black dark:text-white">
{props.symbol}
</h3>
{props.exchange && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-light-100 dark:bg-dark-100 text-black/60 dark:text-white/60">
{props.exchange}
</span>
)}
{isMarketOpen && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-950/40 border border-green-300 dark:border-green-800">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-medium text-green-700 dark:text-green-400">
Live
</span>
</div>
)}
{isPreMarket && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-950/40 border border-blue-300 dark:border-blue-800">
<Clock className="w-3 h-3 text-blue-600 dark:text-blue-400" />
<span className="text-xs font-medium text-blue-700 dark:text-blue-400">
Pre-Market
</span>
</div>
)}
{isPostMarket && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-orange-100 dark:bg-orange-950/40 border border-orange-300 dark:border-orange-800">
<Clock className="w-3 h-3 text-orange-600 dark:text-orange-400" />
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
After Hours
</span>
</div>
)}
</div>
<p className="text-sm text-black/60 dark:text-white/60">
{props.longName || props.shortName}
</p>
</div>
<div className="text-right">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-3xl font-medium text-black dark:text-white">
{props.currency === 'USD' ? '$' : ''}
{formatNumber(displayPrice)}
</span>
</div>
<div
className={`flex items-center justify-end gap-1 ${changeColor}`}
>
{isPositive ? (
<ArrowUpRight className="w-4 h-4" />
) : displayChange === 0 ? (
<Minus className="w-4 h-4" />
) : (
<ArrowDownRight className="w-4 h-4" />
)}
<span className="text-lg font-normal">
{displayChange !== undefined && displayChange >= 0 ? '+' : ''}
{formatNumber(displayChange)}
</span>
<span className="text-sm font-normal">
(
{displayChangePercent !== undefined && displayChangePercent >= 0
? '+'
: ''}
{formatNumber(displayChangePercent)}%)
</span>
</div>
</div>
</div>
{props.chartData && (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 border-b border-light-200 dark:border-dark-200">
<div className="flex items-center gap-1">
{(['1D', '5D', '1M', '3M', '6M', '1Y', 'MAX'] as const).map(
(timeframe) => (
<button
key={timeframe}
onClick={() => setSelectedTimeframe(timeframe)}
disabled={!props.chartData?.[timeframe]}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
selectedTimeframe === timeframe
? 'bg-black/10 dark:bg-white/10 text-black dark:text-white'
: 'text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80'
} disabled:opacity-30 disabled:cursor-not-allowed`}
>
{timeframe}
</button>
),
)}
</div>
{props.comparisonData && props.comparisonData.length > 0 && (
<div className="flex items-center gap-3 ml-auto">
<span className="text-xs text-black/50 dark:text-white/50">
{props.symbol}
</span>
{props.comparisonData.map((comp, index) => {
const colors = ['#8b5cf6', '#f59e0b', '#ec4899'];
return (
<div
key={comp.ticker}
className="flex items-center gap-1.5"
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: colors[index] }}
/>
<span className="text-xs text-black/70 dark:text-white/70">
{comp.ticker}
</span>
</div>
);
})}
</div>
)}
</div>
<div className="p-4">
<div ref={chartContainerRef} />
</div>
<div className="grid grid-cols-3 border-t border-light-200 dark:border-dark-200">
<div className="flex justify-between p-3 border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Prev Close
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketPreviousClose)}
</span>
</div>
<div className="flex justify-between p-3 border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
52W Range
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.fiftyTwoWeekLow, 2)}-$
{formatNumber(props.fiftyTwoWeekHigh, 2)}
</span>
</div>
<div className="flex justify-between p-3">
<span className="text-xs text-black/50 dark:text-white/50">
Market Cap
</span>
<span className="text-xs text-black dark:text-white font-medium">
{formatLargeNumber(props.marketCap)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Open
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketOpen)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
P/E Ratio
</span>
<span className="text-xs text-black dark:text-white font-medium">
{props.trailingPE ? formatNumber(props.trailingPE, 2) : 'N/A'}
</span>
</div>
<div className="flex justify-between p-3 border-t border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Dividend Yield
</span>
<span className="text-xs text-black dark:text-white font-medium">
{props.dividendYield
? `${formatNumber(props.dividendYield * 100, 2)}%`
: 'N/A'}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Day Range
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketDayLow, 2)}-$
{formatNumber(props.regularMarketDayHigh, 2)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Volume
</span>
<span className="text-xs text-black dark:text-white font-medium">
{formatLargeNumber(props.regularMarketVolume)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
EPS
</span>
<span className="text-xs text-black dark:text-white font-medium">
$
{props.earningsPerShare
? formatNumber(props.earningsPerShare, 2)
: 'N/A'}
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Stock;

View File

@@ -0,0 +1,408 @@
'use client';
import { Wind, Droplets, Gauge } from 'lucide-react';
import { useMemo, useEffect, useState } from 'react';
type WeatherWidgetProps = {
location: string;
current: {
time: string;
temperature_2m: number;
relative_humidity_2m: number;
apparent_temperature: number;
is_day: number;
precipitation: number;
weather_code: number;
wind_speed_10m: number;
wind_direction_10m: number;
wind_gusts_10m?: number;
};
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_probability_max: number[];
};
timezone: string;
};
const getWeatherInfo = (code: number, isDay: boolean, isDarkMode: boolean) => {
const dayNight = isDay ? 'day' : 'night';
const weatherMap: Record<
number,
{ icon: string; description: string; gradient: string }
> = {
0: {
icon: `clear-${dayNight}.svg`,
description: 'Clear',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E8F1FA, #7A9DBF 35%, #4A7BA8 60%, #2F5A88)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #5A6A7E, #3E4E63 40%, #2A3544 65%, #1A2230)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #DBEAFE 30%, #93C5FD 60%, #60A5FA)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #7B8694, #475569 45%, #334155 70%, #1E293B)',
},
1: {
icon: `clear-${dayNight}.svg`,
description: 'Mostly Clear',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E8F1FA, #7A9DBF 35%, #4A7BA8 60%, #2F5A88)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #5A6A7E, #3E4E63 40%, #2A3544 65%, #1A2230)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #DBEAFE 30%, #93C5FD 60%, #60A5FA)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #7B8694, #475569 45%, #334155 70%, #1E293B)',
},
2: {
icon: `cloudy-1-${dayNight}.svg`,
description: 'Partly Cloudy',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E1ED, #8BA3B8 35%, #617A93 60%, #426070)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #6B7583, #4A5563 40%, #3A4450 65%, #2A3340)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E0F2FE 28%, #BFDBFE 58%, #93C5FD)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #8B99AB, #64748B 45%, #475569 70%, #334155)',
},
3: {
icon: `cloudy-1-${dayNight}.svg`,
description: 'Cloudy',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8C3CF, #758190 38%, #546270 65%, #3D4A58)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F5F8FA, #CBD5E1 32%, #94A3B8 65%, #64748B)',
},
45: {
icon: `fog-${dayNight}.svg`,
description: 'Foggy',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #C5CDD8, #8892A0 38%, #697380 65%, #4F5A68)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E2E8F0 30%, #CBD5E1 62%, #94A3B8)',
},
48: {
icon: `fog-${dayNight}.svg`,
description: 'Rime Fog',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #C5CDD8, #8892A0 38%, #697380 65%, #4F5A68)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E2E8F0 30%, #CBD5E1 62%, #94A3B8)',
},
51: {
icon: `rainy-1-${dayNight}.svg`,
description: 'Light Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8D4E5, #6FA4C5 35%, #4A85AC 60%, #356A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5FBFF, #A5F3FC 28%, #67E8F9 60%, #22D3EE)',
},
53: {
icon: `rainy-1-${dayNight}.svg`,
description: 'Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8D4E5, #6FA4C5 35%, #4A85AC 60%, #356A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5FBFF, #A5F3FC 28%, #67E8F9 60%, #22D3EE)',
},
55: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Heavy Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
61: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Light Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
63: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8DB3C8, #4D819F 38%, #326A87 65%, #215570)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8E8FF, #38BDF8 32%, #0EA5E9 65%, #0284C7)',
},
65: {
icon: `rainy-3-${dayNight}.svg`,
description: 'Heavy Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7BA3B8, #3D6F8A 38%, #295973 65%, #1A455D)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9CD9F5, #0EA5E9 32%, #0284C7 65%, #0369A1)',
},
71: {
icon: `snowy-1-${dayNight}.svg`,
description: 'Light Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5F0FA, #9BB5CE 32%, #7496B8 58%, #527A9E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #F0F9FF 25%, #E0F2FE 55%, #BAE6FD)',
},
73: {
icon: `snowy-2-${dayNight}.svg`,
description: 'Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E5F3, #85A1BD 35%, #6584A8 60%, #496A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FAFEFF, #E0F2FE 28%, #BAE6FD 60%, #7DD3FC)',
},
75: {
icon: `snowy-3-${dayNight}.svg`,
description: 'Heavy Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #BDD8EB, #6F92AE 35%, #4F7593 60%, #365A78)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F0FAFF, #BAE6FD 30%, #7DD3FC 62%, #38BDF8)',
},
77: {
icon: `snowy-1-${dayNight}.svg`,
description: 'Snow Grains',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5F0FA, #9BB5CE 32%, #7496B8 58%, #527A9E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #F0F9FF 25%, #E0F2FE 55%, #BAE6FD)',
},
80: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Light Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
81: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8DB3C8, #4D819F 38%, #326A87 65%, #215570)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8E8FF, #38BDF8 32%, #0EA5E9 65%, #0284C7)',
},
82: {
icon: `rainy-3-${dayNight}.svg`,
description: 'Heavy Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7BA3B8, #3D6F8A 38%, #295973 65%, #1A455D)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9CD9F5, #0EA5E9 32%, #0284C7 65%, #0369A1)',
},
85: {
icon: `snowy-2-${dayNight}.svg`,
description: 'Light Snow Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E5F3, #85A1BD 35%, #6584A8 60%, #496A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FAFEFF, #E0F2FE 28%, #BAE6FD 60%, #7DD3FC)',
},
86: {
icon: `snowy-3-${dayNight}.svg`,
description: 'Snow Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #BDD8EB, #6F92AE 35%, #4F7593 60%, #365A78)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F0FAFF, #BAE6FD 30%, #7DD3FC 62%, #38BDF8)',
},
95: {
icon: `scattered-thunderstorms-${dayNight}.svg`,
description: 'Thunderstorm',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8A95A3, #5F6A7A 38%, #475260 65%, #2F3A48)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #C8D1DD, #94A3B8 32%, #64748B 65%, #475569)',
},
96: {
icon: 'severe-thunderstorm.svg',
description: 'Thunderstorm + Hail',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7A8593, #515C6D 38%, #3A4552 65%, #242D3A)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B0BBC8, #64748B 32%, #475569 65%, #334155)',
},
99: {
icon: 'severe-thunderstorm.svg',
description: 'Severe Thunderstorm',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #6A7583, #434E5D 40%, #2F3A47 68%, #1C2530)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9BA8B8, #475569 35%, #334155 68%, #1E293B)',
},
};
return weatherMap[code] || weatherMap[0];
};
const Weather = ({
location,
current,
daily,
timezone,
}: WeatherWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
const weatherInfo = useMemo(
() =>
getWeatherInfo(
current?.weather_code || 0,
current?.is_day === 1,
isDarkMode,
),
[current?.weather_code, current?.is_day, isDarkMode],
);
const forecast = useMemo(() => {
if (!daily?.time || daily.time.length === 0) return [];
return daily.time.slice(1, 7).map((time, idx) => {
const date = new Date(time);
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
const isDay = true;
const weatherCode = daily.weather_code[idx + 1];
const info = getWeatherInfo(weatherCode, isDay, isDarkMode);
return {
day: dayName,
icon: info.icon,
high: Math.round(daily.temperature_2m_max[idx + 1]),
low: Math.round(daily.temperature_2m_min[idx + 1]),
highF: Math.round((daily.temperature_2m_max[idx + 1] * 9) / 5 + 32),
lowF: Math.round((daily.temperature_2m_min[idx + 1] * 9) / 5 + 32),
precipitation: daily.precipitation_probability_max[idx + 1] || 0,
};
});
}, [daily, isDarkMode]);
if (!current || !daily || !daily.time || daily.time.length === 0) {
return (
<div className="relative overflow-hidden rounded-lg shadow-md bg-gray-200 dark:bg-gray-800">
<div className="p-4 text-black dark:text-white">
<p className="text-sm">Weather data unavailable for {location}</p>
</div>
</div>
);
}
return (
<div className="relative overflow-hidden rounded-lg shadow-md">
<div
className="absolute inset-0"
style={{
background: weatherInfo.gradient,
}}
/>
<div className="relative p-4 text-gray-800 dark:text-white">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<img
src={`/weather-ico/${weatherInfo.icon}`}
alt={weatherInfo.description}
className="w-16 h-16 drop-shadow-lg"
/>
<div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold drop-shadow-md">
{Math.round(current.temperature_2m)}°
</span>
<span className="text-lg">F C</span>
</div>
<p className="text-sm font-medium drop-shadow mt-0.5">
{weatherInfo.description}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-medium opacity-90">
{Math.round(daily.temperature_2m_max[0])}°{' '}
{Math.round(daily.temperature_2m_min[0])}°
</p>
</div>
</div>
<div className="mb-3 pb-3 border-b border-gray-800/20 dark:border-white/20">
<h3 className="text-base font-semibold drop-shadow-md">{location}</h3>
<p className="text-xs text-gray-700 dark:text-white/80 drop-shadow mt-0.5">
{new Date(current.time).toLocaleString('en-US', {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
<div className="grid grid-cols-6 gap-2 mb-3 pb-3 border-b border-gray-800/20 dark:border-white/20">
{forecast.map((day, idx) => (
<div
key={idx}
className="flex flex-col items-center bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2"
>
<p className="text-xs font-medium mb-1">{day.day}</p>
<img
src={`/weather-ico/${day.icon}`}
alt=""
className="w-8 h-8 mb-1"
/>
<div className="flex items-center gap-1 text-xs">
<span className="font-semibold">{day.high}°</span>
<span className="text-gray-600 dark:text-white/60">
{day.low}°
</span>
</div>
{day.precipitation > 0 && (
<div className="flex items-center gap-0.5 mt-1">
<Droplets className="w-3 h-3 text-gray-600 dark:text-white/70" />
<span className="text-[10px] text-gray-600 dark:text-white/70">
{day.precipitation}%
</span>
</div>
)}
</div>
))}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Wind className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Wind
</p>
<p className="font-semibold">
{Math.round(current.wind_speed_10m)} km/h
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Droplets className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Humidity
</p>
<p className="font-semibold">
{Math.round(current.relative_humidity_2m)}%
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Gauge className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Feels Like
</p>
<p className="font-semibold">
{Math.round(current.apparent_temperature)}°C
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default Weather;