fix: wind speed in m/s, redesigned weather modal with hero section and forecast cards
All checks were successful
Deploy / deploy (push) Successful in 2m55s

This commit is contained in:
Cosmo
2026-04-22 19:35:46 +00:00
parent 868d35ba3e
commit eed8db5865
2 changed files with 166 additions and 109 deletions

View File

@@ -77,7 +77,7 @@ export async function GET() {
humidity: String(current.relative_humidity_2m), humidity: String(current.relative_humidity_2m),
desc: wmoToDesc(current.weather_code), desc: wmoToDesc(current.weather_code),
weatherCode: wmoToWttrCode(current.weather_code), weatherCode: wmoToWttrCode(current.weather_code),
windSpeed: String(Math.round(current.wind_speed_10m)), windSpeed: String(Math.round(current.wind_speed_10m / 3.6)),
forecast, forecast,
}); });
} catch (e) { } catch (e) {

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Droplets, Wind, Thermometer, X } from 'lucide-react' import { Droplets, Wind, Thermometer, X, CloudRain, Snowflake, CloudLightning, Cloud, Sun, CloudSun } from 'lucide-react'
interface WeatherData { interface WeatherData {
temp: string temp: string
@@ -26,11 +26,13 @@ interface TopBarProps {
function getWeatherIcon(desc: string): string { function getWeatherIcon(desc: string): string {
const d = desc?.toLowerCase() || '' const d = desc?.toLowerCase() || ''
if (d.includes('ясно') || d.includes('солнеч')) return '☀️' if (d.includes('ясно') || d.includes('солнеч')) return '☀️'
if (d.includes('облач')) return '⛅' if (d.includes('облач') || d.includes('перем')) return '⛅'
if (d.includes('пасмурн')) return '☁️' if (d.includes('пасмурн')) return '☁️'
if (d.includes('дождь') || d.includes('морос')) return '🌧' if (d.includes('морос')) return '🌦'
if (d.includes('снег')) return '' if (d.includes('дождь') || d.includes('ливен')) return '🌧'
if (d.includes('снег')) return '🌨️'
if (d.includes('гроз')) return '⛈️' if (d.includes('гроз')) return '⛈️'
if (d.includes('туман')) return '🌫️'
return '🌤️' return '🌤️'
} }
@@ -45,6 +47,16 @@ function formatDate(date: Date): string {
return `${weekday}, ${day} ${month}` return `${weekday}, ${day} ${month}`
} }
function getWindDesc(ms: number): string {
if (ms <= 1) return 'Штиль'
if (ms <= 3) return 'Тихий'
if (ms <= 5) return 'Лёгкий'
if (ms <= 8) return 'Умеренный'
if (ms <= 11) return 'Свежий'
if (ms <= 14) return 'Сильный'
return 'Шторм'
}
export default function TopBar({ weather, sensors }: TopBarProps) { export default function TopBar({ weather, sensors }: TopBarProps) {
const [time, setTime] = useState(() => new Date()) const [time, setTime] = useState(() => new Date())
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
@@ -54,6 +66,8 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
const windMs = weather ? parseInt(weather.windSpeed) || 0 : 0
return ( return (
<> <>
<header <header
@@ -73,19 +87,14 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
{/* Left: time + date */} {/* Left: time + date */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
<span style={{ <span style={{
fontSize: 28, fontSize: 28, fontWeight: 700, color: 'var(--text-primary)',
fontWeight: 700, letterSpacing: '-1px', fontVariantNumeric: 'tabular-nums',
color: 'var(--text-primary)',
letterSpacing: '-1px',
fontVariantNumeric: 'tabular-nums',
}}> }}>
{formatTime(time)} {formatTime(time)}
</span> </span>
<span style={{ <span style={{
fontSize: 14, fontSize: 14, color: 'var(--text-secondary)',
color: 'var(--text-secondary)', fontWeight: 400, textTransform: 'capitalize',
fontWeight: 400,
textTransform: 'capitalize',
}}> }}>
{formatDate(time)} {formatDate(time)}
</span> </span>
@@ -93,16 +102,11 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
{/* Right: sensors + weather */} {/* Right: sensors + weather */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Room sensors */}
{sensors && ( {sensors && (
<div style={{ <div style={{
display: 'flex', display: 'flex', alignItems: 'center', gap: 4,
alignItems: 'center', padding: '8px 14px', borderRadius: 14,
gap: 4, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
padding: '8px 14px',
borderRadius: 14,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.05)',
}}> }}>
<Thermometer size={14} color="rgba(255,255,255,0.35)" /> <Thermometer size={14} color="rgba(255,255,255,0.35)" />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 8 }}> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 8 }}>
@@ -119,20 +123,15 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
</div> </div>
)} )}
{/* Weather widget */}
{weather && ( {weather && (
<button <button
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
style={{ style={{
display: 'flex', display: 'flex', alignItems: 'center', gap: 8,
alignItems: 'center', padding: '8px 16px', borderRadius: 14,
gap: 8,
padding: '8px 16px',
borderRadius: 14,
background: 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.08))', background: 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.08))',
border: '1px solid rgba(129,140,248,0.15)', border: '1px solid rgba(129,140,248,0.15)',
color: 'var(--text-primary)', color: 'var(--text-primary)', transition: 'all 0.25s ease',
transition: 'all 0.25s ease',
}} }}
> >
<span style={{ fontSize: 20 }}>{getWeatherIcon(weather.desc)}</span> <span style={{ fontSize: 20 }}>{getWeatherIcon(weather.desc)}</span>
@@ -148,97 +147,155 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
<div <div
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
style={{ style={{
position: 'fixed', position: 'fixed', inset: 0,
inset: 0, background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(12px)',
background: 'rgba(0,0,0,0.7)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(12px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
<div <div
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
style={{ style={{
background: 'rgba(18, 18, 35, 0.95)', background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
backdropFilter: 'blur(40px)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
border: '1px solid rgba(255,255,255,0.08)', width: 480, maxWidth: '95vw', overflow: 'hidden',
borderRadius: 24, boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
padding: 32,
minWidth: 360,
maxWidth: 420,
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}> {/* Hero section */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ fontSize: 48 }}>{getWeatherIcon(weather.desc)}</span>
<div>
<div style={{ fontSize: 40, fontWeight: 800, lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
<div style={{ fontSize: 15, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
</div>
</div>
<button onClick={() => setShowModal(false)} style={{ color: 'var(--text-secondary)', padding: 4 }}>
<X size={20} />
</button>
</div>
<div style={{ <div style={{
display: 'grid', background: 'linear-gradient(135deg, rgba(59,130,246,0.12), rgba(99,102,241,0.06))',
gridTemplateColumns: 'repeat(3, 1fr)', borderBottom: '1px solid rgba(59,130,246,0.1)',
gap: 10, padding: '32px 36px 28px',
marginBottom: 24, position: 'relative', overflow: 'hidden',
}}> }}>
{[ {/* Background emoji */}
{ icon: <Droplets size={16} />, label: 'Влажность', value: `${weather.humidity}%` }, <div style={{ position: 'absolute', top: -10, right: 10, fontSize: 100, opacity: 0.08, pointerEvents: 'none' }}>
{ icon: <Wind size={16} />, label: 'Ветер', value: `${weather.windSpeed} км/ч` }, {getWeatherIcon(weather.desc)}
{ icon: <Thermometer size={16} />, label: 'Ощущается', value: `${weather.feelsLike}°` }, </div>
].map(item => (
<div key={item.label} style={{ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative', zIndex: 1 }}>
padding: '14px 12px', <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
borderRadius: 16, <span style={{ fontSize: 56 }}>{getWeatherIcon(weather.desc)}</span>
background: 'rgba(255,255,255,0.04)', <div>
border: '1px solid rgba(255,255,255,0.06)', <div style={{ fontSize: 48, fontWeight: 800, lineHeight: 1, letterSpacing: '-3px', color: 'var(--text-primary)' }}>
textAlign: 'center', {weather.temp}°
}}> </div>
<div style={{ color: 'var(--text-secondary)', marginBottom: 8, display: 'flex', justifyContent: 'center' }}>{item.icon}</div> <div style={{ fontSize: 16, color: 'var(--text-secondary)', marginTop: 6, fontWeight: 500 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{item.value}</div> {weather.desc}
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 4 }}>{item.label}</div> </div>
</div>
</div> </div>
))} <button onClick={() => setShowModal(false)} style={{
color: 'var(--text-secondary)', padding: 8, borderRadius: 12,
background: 'rgba(255,255,255,0.05)',
}}>
<X size={18} />
</button>
</div>
</div> </div>
{weather.forecast && weather.forecast.length > 0 && ( {/* Details */}
<> <div style={{ padding: '24px 36px 32px' }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 14 }}>
Прогноз {/* Stats grid */}
</div> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 28 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> {[
{weather.forecast.map(day => { {
const d = new Date(day.date) icon: <Thermometer size={18} color="#fb923c" />,
const label = d.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' }) bg: 'rgba(251,146,60,0.1)',
return ( label: 'Ощущается',
<div key={day.date} style={{ value: `${weather.feelsLike}°`,
display: 'flex', },
alignItems: 'center', {
justifyContent: 'space-between', icon: <Droplets size={18} color="#3b82f6" />,
padding: '10px 14px', bg: 'rgba(59,130,246,0.1)',
borderRadius: 12, label: 'Влажность',
background: 'rgba(255,255,255,0.03)', value: `${weather.humidity}%`,
}}> },
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100, textTransform: 'capitalize' }}>{label}</span> {
<span style={{ fontSize: 18 }}>{getWeatherIcon(day.desc)}</span> icon: <Wind size={18} color="#22d3ee" />,
<span style={{ fontSize: 12, color: 'var(--text-secondary)', minWidth: 80, textAlign: 'center' }}>{day.desc}</span> bg: 'rgba(34,211,238,0.1)',
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}> label: getWindDesc(windMs),
{day.maxTemp}° <span style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>/ {day.minTemp}°</span> value: `${weather.windSpeed} м/с`,
</span> },
</div> ].map(item => (
) <div key={item.label} style={{
})} padding: '18px 14px', borderRadius: 18,
</div> background: 'rgba(255,255,255,0.03)',
</> border: '1px solid rgba(255,255,255,0.05)',
)} display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
}}>
<div style={{
width: 40, height: 40, borderRadius: 12,
background: item.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{item.icon}
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>{item.value}</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 500 }}>{item.label}</div>
</div>
))}
</div>
{/* Forecast */}
{weather.forecast && weather.forecast.length > 0 && (
<>
<div style={{
fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase',
letterSpacing: '0.1em', fontWeight: 600, marginBottom: 14,
}}>
Прогноз на ближайшие дни
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{weather.forecast.map(day => {
const d = new Date(day.date)
const isToday = d.toDateString() === new Date().toDateString()
const weekday = d.toLocaleDateString('ru-RU', { weekday: 'long' })
const dateStr = d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
return (
<div key={day.date} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 18px', borderRadius: 16,
background: isToday
? 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.05))'
: 'rgba(255,255,255,0.025)',
border: isToday
? '1px solid rgba(129,140,248,0.15)'
: '1px solid rgba(255,255,255,0.04)',
}}>
{/* Icon */}
<span style={{ fontSize: 28, flexShrink: 0 }}>{getWeatherIcon(day.desc)}</span>
{/* Day info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 600, color: 'var(--text-primary)',
textTransform: 'capitalize',
}}>
{isToday ? 'Сегодня' : weekday}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>
{dateStr} · {day.desc}
</div>
</div>
{/* Temps */}
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
{day.maxTemp}°
</span>
<span style={{ fontSize: 14, color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 4 }}>
{day.minTemp}°
</span>
</div>
</div>
)
})}
</div>
</>
)}
</div>
</div> </div>
</div> </div>
)} )}