feat: animated SVG weather icons + dynamic gradient background by weather/time
Some checks failed
Deploy / deploy (push) Failing after 2m6s
Some checks failed
Deploy / deploy (push) Failing after 2m6s
This commit is contained in:
@@ -219,3 +219,83 @@ button:focus-visible {
|
|||||||
.device-active-breathe {
|
.device-active-breathe {
|
||||||
animation: device-breathe 3s ease-in-out infinite;
|
animation: device-breathe 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ————— Weather animations ————— */
|
||||||
|
@keyframes spin-slow {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cloud-float {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
50% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rain-fall {
|
||||||
|
0% { transform: translateY(0); opacity: 0.7; }
|
||||||
|
80% { opacity: 0.7; }
|
||||||
|
100% { transform: translateY(16px); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes snow-fall {
|
||||||
|
0% { transform: translateY(0) rotate(0deg); opacity: 0.8; }
|
||||||
|
50% { transform: translateY(10px) translateX(3px) rotate(180deg); opacity: 0.6; }
|
||||||
|
100% { transform: translateY(20px) rotate(360deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thunder-flash {
|
||||||
|
0%, 100% { opacity: 0; }
|
||||||
|
5%, 7% { opacity: 1; }
|
||||||
|
6% { opacity: 0.3; }
|
||||||
|
50%, 52% { opacity: 0.8; }
|
||||||
|
51% { opacity: 0.2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fog-drift {
|
||||||
|
0%, 100% { transform: translateX(0); opacity: 0.4; }
|
||||||
|
50% { transform: translateX(8px); opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— Dynamic weather backgrounds ————— */
|
||||||
|
.weather-bg-clear {
|
||||||
|
--orb1-color: rgba(251, 191, 36, 0.08);
|
||||||
|
--orb2-color: rgba(245, 158, 11, 0.05);
|
||||||
|
}
|
||||||
|
.weather-bg-cloudy {
|
||||||
|
--orb1-color: rgba(148, 163, 184, 0.08);
|
||||||
|
--orb2-color: rgba(100, 116, 139, 0.06);
|
||||||
|
}
|
||||||
|
.weather-bg-rain {
|
||||||
|
--orb1-color: rgba(59, 130, 246, 0.08);
|
||||||
|
--orb2-color: rgba(30, 64, 175, 0.06);
|
||||||
|
}
|
||||||
|
.weather-bg-snow {
|
||||||
|
--orb1-color: rgba(186, 230, 253, 0.1);
|
||||||
|
--orb2-color: rgba(147, 197, 253, 0.07);
|
||||||
|
}
|
||||||
|
.weather-bg-thunder {
|
||||||
|
--orb1-color: rgba(139, 92, 246, 0.1);
|
||||||
|
--orb2-color: rgba(88, 28, 135, 0.06);
|
||||||
|
}
|
||||||
|
.weather-bg-night {
|
||||||
|
--orb1-color: rgba(67, 56, 202, 0.06);
|
||||||
|
--orb2-color: rgba(49, 46, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-bg-clear .bg-ambient::before,
|
||||||
|
.weather-bg-cloudy .bg-ambient::before,
|
||||||
|
.weather-bg-rain .bg-ambient::before,
|
||||||
|
.weather-bg-snow .bg-ambient::before,
|
||||||
|
.weather-bg-thunder .bg-ambient::before,
|
||||||
|
.weather-bg-night .bg-ambient::before {
|
||||||
|
background: radial-gradient(circle, var(--orb1-color) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-bg-clear .bg-ambient::after,
|
||||||
|
.weather-bg-cloudy .bg-ambient::after,
|
||||||
|
.weather-bg-rain .bg-ambient::after,
|
||||||
|
.weather-bg-snow .bg-ambient::after,
|
||||||
|
.weather-bg-thunder .bg-ambient::after,
|
||||||
|
.weather-bg-night .bg-ambient::after {
|
||||||
|
background: radial-gradient(circle, var(--orb2-color) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|||||||
19
app/page.tsx
19
app/page.tsx
@@ -8,6 +8,7 @@ import TopBar from '@/components/TopBar'
|
|||||||
import RoomTabs from '@/components/RoomTabs'
|
import RoomTabs from '@/components/RoomTabs'
|
||||||
import DeviceCard from '@/components/DeviceCard'
|
import DeviceCard from '@/components/DeviceCard'
|
||||||
import CalendarTab from '@/components/CalendarTab'
|
import CalendarTab from '@/components/CalendarTab'
|
||||||
|
import WeatherAnimation from '@/components/WeatherAnimation'
|
||||||
|
|
||||||
type Tab = 'home' | 'devices' | 'calendar' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'settings'
|
||||||
|
|
||||||
@@ -91,6 +92,18 @@ function formatEventTime(iso: string): string {
|
|||||||
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWeatherBgClass(desc: string | null): string {
|
||||||
|
if (!desc) return ''
|
||||||
|
const d = desc.toLowerCase()
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
if (hour >= 22 || hour < 5) return 'weather-bg-night'
|
||||||
|
if (d.includes('гроз')) return 'weather-bg-thunder'
|
||||||
|
if (d.includes('снег')) return 'weather-bg-snow'
|
||||||
|
if (d.includes('дождь') || d.includes('ливен') || d.includes('морос')) return 'weather-bg-rain'
|
||||||
|
if (d.includes('пасмурн') || d.includes('облач') || d.includes('туман')) return 'weather-bg-cloudy'
|
||||||
|
return 'weather-bg-clear'
|
||||||
|
}
|
||||||
|
|
||||||
function getGreeting(): string {
|
function getGreeting(): string {
|
||||||
const h = new Date().getHours()
|
const h = new Date().getHours()
|
||||||
if (h >= 5 && h < 12) return 'Доброе утро'
|
if (h >= 5 && h < 12) return 'Доброе утро'
|
||||||
@@ -161,7 +174,7 @@ function Screensaver({ weather, onDismiss }: { weather: WeatherData | null; onDi
|
|||||||
display: 'flex', alignItems: 'center', gap: 12,
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
marginTop: 16, color: 'rgba(255,255,255,0.3)',
|
marginTop: 16, color: 'rgba(255,255,255,0.3)',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 28 }}>{getWeatherIcon(weather.desc)}</span>
|
<WeatherAnimation condition={weather.desc} size={36} />
|
||||||
<span style={{ fontSize: 24, fontWeight: 600 }}>{weather.temp}°</span>
|
<span style={{ fontSize: 24, fontWeight: 600 }}>{weather.temp}°</span>
|
||||||
<span style={{ fontSize: 16 }}>{weather.desc}</span>
|
<span style={{ fontSize: 16 }}>{weather.desc}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +326,7 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
|||||||
<div style={{ position: 'absolute', top: -20, right: -10, fontSize: 80, opacity: 0.12, pointerEvents: 'none' }}>{getWeatherIcon(weather.desc)}</div>
|
<div style={{ position: 'absolute', top: -20, right: -10, fontSize: 80, opacity: 0.12, pointerEvents: 'none' }}>{getWeatherIcon(weather.desc)}</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>Погода</div>
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>Погода</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18, position: 'relative', zIndex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18, position: 'relative', zIndex: 1 }}>
|
||||||
<span style={{ fontSize: 44 }}>{getWeatherIcon(weather.desc)}</span>
|
<WeatherAnimation condition={weather.desc} size={56} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 36, fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
|
<div style={{ fontSize: 36, fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
|
||||||
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
|
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
|
||||||
@@ -678,7 +691,7 @@ function HomePageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100dvh', width: '100%', background: 'var(--bg)', overflow: 'hidden', position: 'relative' }}>
|
<div className={getWeatherBgClass(weather?.desc || null)} style={{ display: 'flex', height: '100dvh', width: '100%', background: 'var(--bg)', overflow: 'hidden', position: 'relative' }}>
|
||||||
<div className="bg-ambient" />
|
<div className="bg-ambient" />
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Droplets, Wind, Thermometer, X, CloudRain, Snowflake, CloudLightning, Cloud, Sun, CloudSun } from 'lucide-react'
|
import { Droplets, Wind, Thermometer, X } from 'lucide-react'
|
||||||
|
import WeatherAnimation from '@/components/WeatherAnimation'
|
||||||
|
|
||||||
interface WeatherData {
|
interface WeatherData {
|
||||||
temp: string
|
temp: string
|
||||||
@@ -179,13 +180,13 @@ export default function TopBar({ weather, sensors, haConnected }: TopBarProps) {
|
|||||||
position: 'relative', overflow: 'hidden',
|
position: 'relative', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{/* Background emoji */}
|
{/* Background emoji */}
|
||||||
<div style={{ position: 'absolute', top: -10, right: 10, fontSize: 100, opacity: 0.08, pointerEvents: 'none' }}>
|
<div style={{ position: 'absolute', top: -5, right: 5, opacity: 0.1, pointerEvents: 'none' }}>
|
||||||
{getWeatherIcon(weather.desc)}
|
<WeatherAnimation condition={weather.desc} size={120} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative', zIndex: 1 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative', zIndex: 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||||
<span style={{ fontSize: 56 }}>{getWeatherIcon(weather.desc)}</span>
|
<WeatherAnimation condition={weather.desc} size={72} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 48, fontWeight: 800, lineHeight: 1, letterSpacing: '-3px', color: 'var(--text-primary)' }}>
|
<div style={{ fontSize: 48, fontWeight: 800, lineHeight: 1, letterSpacing: '-3px', color: 'var(--text-primary)' }}>
|
||||||
{weather.temp}°
|
{weather.temp}°
|
||||||
@@ -275,7 +276,7 @@ export default function TopBar({ weather, sensors, haConnected }: TopBarProps) {
|
|||||||
: '1px solid rgba(255,255,255,0.04)',
|
: '1px solid rgba(255,255,255,0.04)',
|
||||||
}}>
|
}}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<span style={{ fontSize: 28, flexShrink: 0 }}>{getWeatherIcon(day.desc)}</span>
|
<WeatherAnimation condition={day.desc} size={36} />
|
||||||
|
|
||||||
{/* Day info */}
|
{/* Day info */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|||||||
151
components/WeatherAnimation.tsx
Normal file
151
components/WeatherAnimation.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
interface WeatherAnimationProps {
|
||||||
|
condition: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCondition(desc: string): 'clear' | 'partly' | 'cloudy' | 'rain' | 'snow' | 'thunder' | 'fog' {
|
||||||
|
const d = desc?.toLowerCase() || ''
|
||||||
|
if (d.includes('гроз')) return 'thunder'
|
||||||
|
if (d.includes('снег') || d.includes('снегопад')) return 'snow'
|
||||||
|
if (d.includes('дождь') || d.includes('ливен') || d.includes('морос')) return 'rain'
|
||||||
|
if (d.includes('туман')) return 'fog'
|
||||||
|
if (d.includes('пасмурн')) return 'cloudy'
|
||||||
|
if (d.includes('облач') || d.includes('перем')) return 'partly'
|
||||||
|
return 'clear'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeatherAnimation({ condition, size = 64 }: WeatherAnimationProps) {
|
||||||
|
const c = getCondition(condition)
|
||||||
|
const s = size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: s, height: s, position: 'relative', flexShrink: 0 }}>
|
||||||
|
<svg viewBox="0 0 100 100" width={s} height={s} style={{ overflow: 'visible' }}>
|
||||||
|
{/* Sun */}
|
||||||
|
{(c === 'clear' || c === 'partly') && (
|
||||||
|
<g style={{ transformOrigin: c === 'partly' ? '35px 40px' : '50px 50px' }}>
|
||||||
|
<circle
|
||||||
|
cx={c === 'partly' ? 35 : 50}
|
||||||
|
cy={c === 'partly' ? 40 : 50}
|
||||||
|
r={c === 'partly' ? 14 : 18}
|
||||||
|
fill="#fbbf24"
|
||||||
|
style={{ filter: 'drop-shadow(0 0 8px rgba(251,191,36,0.6))' }}
|
||||||
|
/>
|
||||||
|
{/* Rays */}
|
||||||
|
{[0,45,90,135,180,225,270,315].map(angle => (
|
||||||
|
<line
|
||||||
|
key={angle}
|
||||||
|
x1={c === 'partly' ? 35 : 50}
|
||||||
|
y1={c === 'partly' ? 40 : 50}
|
||||||
|
x2={c === 'partly' ? 35 + Math.cos(angle * Math.PI / 180) * 22 : 50 + Math.cos(angle * Math.PI / 180) * 28}
|
||||||
|
y2={c === 'partly' ? 40 + Math.sin(angle * Math.PI / 180) * 22 : 50 + Math.sin(angle * Math.PI / 180) * 28}
|
||||||
|
stroke="#fbbf24"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.6}
|
||||||
|
style={{
|
||||||
|
transformOrigin: `${c === 'partly' ? 35 : 50}px ${c === 'partly' ? 40 : 50}px`,
|
||||||
|
animation: `spin-slow 12s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cloud */}
|
||||||
|
{(c === 'partly' || c === 'cloudy' || c === 'rain' || c === 'snow' || c === 'thunder') && (
|
||||||
|
<g style={{ animation: 'cloud-float 4s ease-in-out infinite' }}>
|
||||||
|
<ellipse cx={58} cy={52} rx={22} ry={14} fill="rgba(200,210,230,0.9)" />
|
||||||
|
<ellipse cx={45} cy={48} rx={16} ry={12} fill="rgba(210,220,235,0.95)" />
|
||||||
|
<ellipse cx={68} cy={50} rx={14} ry={10} fill="rgba(195,205,225,0.85)" />
|
||||||
|
<ellipse cx={52} cy={56} rx={20} ry={10} fill="rgba(205,215,232,0.9)" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rain drops */}
|
||||||
|
{(c === 'rain' || c === 'thunder') && (
|
||||||
|
<g>
|
||||||
|
{[
|
||||||
|
{ x: 42, delay: 0 },
|
||||||
|
{ x: 52, delay: 0.3 },
|
||||||
|
{ x: 62, delay: 0.6 },
|
||||||
|
{ x: 47, delay: 0.9 },
|
||||||
|
{ x: 57, delay: 0.15 },
|
||||||
|
].map((drop, i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={drop.x} y1={68} x2={drop.x - 2} y2={76}
|
||||||
|
stroke="#60a5fa"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={0.7}
|
||||||
|
style={{
|
||||||
|
animation: `rain-fall 0.8s ease-in infinite`,
|
||||||
|
animationDelay: `${drop.delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thunder bolt */}
|
||||||
|
{c === 'thunder' && (
|
||||||
|
<polygon
|
||||||
|
points="55,55 50,70 56,68 52,82 62,64 56,66 60,55"
|
||||||
|
fill="#fbbf24"
|
||||||
|
style={{
|
||||||
|
animation: 'thunder-flash 2s ease-in-out infinite',
|
||||||
|
filter: 'drop-shadow(0 0 4px rgba(251,191,36,0.8))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Snow flakes */}
|
||||||
|
{c === 'snow' && (
|
||||||
|
<g>
|
||||||
|
{[
|
||||||
|
{ x: 42, delay: 0 },
|
||||||
|
{ x: 52, delay: 0.4 },
|
||||||
|
{ x: 62, delay: 0.8 },
|
||||||
|
{ x: 47, delay: 1.2 },
|
||||||
|
{ x: 57, delay: 0.2 },
|
||||||
|
].map((flake, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={flake.x} cy={72}
|
||||||
|
r={2.5}
|
||||||
|
fill="white"
|
||||||
|
opacity={0.8}
|
||||||
|
style={{
|
||||||
|
animation: `snow-fall 2s ease-in-out infinite`,
|
||||||
|
animationDelay: `${flake.delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fog lines */}
|
||||||
|
{c === 'fog' && (
|
||||||
|
<g>
|
||||||
|
{[40, 52, 64].map((y, i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={25} y1={y} x2={75} y2={y}
|
||||||
|
stroke="rgba(200,210,230,0.5)"
|
||||||
|
strokeWidth={4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{
|
||||||
|
animation: `fog-drift 3s ease-in-out infinite`,
|
||||||
|
animationDelay: `${i * 0.5}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user