feat: premium UI redesign — glassmorphism, gradient accents, ambient background
All checks were successful
Deploy / deploy (push) Successful in 2m40s

This commit is contained in:
Cosmo
2026-04-22 18:38:31 +00:00
parent 4874466985
commit eb644ff341
8 changed files with 763 additions and 436 deletions

View File

@@ -5,22 +5,37 @@
} }
:root { :root {
--bg: #0a0a14; --bg: #0c0c18;
--sidebar-bg: rgba(255, 255, 255, 0.02); --bg-secondary: #12121f;
--card-bg: rgba(255, 255, 255, 0.05); --sidebar-bg: rgba(12, 12, 24, 0.8);
--card-border: rgba(255, 255, 255, 0.08); --card-bg: rgba(255, 255, 255, 0.04);
--text-primary: rgba(255, 255, 255, 0.92); --card-bg-hover: rgba(255, 255, 255, 0.07);
--card-border: rgba(255, 255, 255, 0.07);
--card-border-hover: rgba(255, 255, 255, 0.12);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.45); --text-secondary: rgba(255, 255, 255, 0.45);
--accent: #00d4ff; --text-tertiary: rgba(255, 255, 255, 0.25);
--accent-glow: rgba(0, 212, 255, 0.15); --accent: #818cf8;
--on-color: #00d4ff; --accent-secondary: #22d3ee;
--off-color: rgba(255, 255, 255, 0.2); --accent-glow: rgba(129, 140, 248, 0.15);
--glass: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.06);
--gradient-primary: linear-gradient(135deg, #6366f1, #8b5cf6);
--gradient-warm: linear-gradient(135deg, #f59e0b, #ef4444);
--gradient-cool: linear-gradient(135deg, #06b6d4, #3b82f6);
--gradient-green: linear-gradient(135deg, #10b981, #34d399);
--on-color: #818cf8;
--off-color: rgba(255, 255, 255, 0.15);
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 22px;
--radius-xl: 28px;
} }
html, body { html, body {
background: var(--bg); background: var(--bg);
color: var(--text-primary); color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -31,6 +46,70 @@ html, body {
height: 100%; height: 100%;
} }
/* Ambient background orbs */
.bg-ambient {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.bg-ambient::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, transparent 70%);
top: -200px;
right: -100px;
animation: float1 20s ease-in-out infinite;
}
.bg-ambient::after {
content: '';
position: absolute;
width: 500px;
height: 500px;
border-radius: 50%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.06) 0%, transparent 70%);
bottom: -150px;
left: -50px;
animation: float2 25s ease-in-out infinite;
}
@keyframes float1 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, 40px) scale(1.05); }
66% { transform: translate(-20px, 20px) scale(0.95); }
}
@keyframes float2 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-40px, -30px) scale(1.1); }
66% { transform: translate(20px, -10px) scale(0.9); }
}
/* Glass card base */
.glass-card {
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--card-border);
border-radius: var(--radius-lg);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
background: var(--card-bg-hover);
border-color: var(--card-border-hover);
}
/* Gradient text */
.gradient-text {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
button { button {
cursor: pointer; cursor: pointer;
border: none; border: none;
@@ -46,20 +125,34 @@ button:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }
/* Smooth scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 4px; width: 4px;
height: 4px; height: 4px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.08);
border-radius: 2px; border-radius: 2px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.15);
}
/* Pulse animation for active devices */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(129, 140, 248, 0.15); }
50% { box-shadow: 0 0 30px rgba(129, 140, 248, 0.25); }
}
/* Slide in animation */
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
} }

View File

@@ -3,7 +3,7 @@ import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Smart Home Dashboard", title: "Smart Home Dashboard",
description: "Smart Home Tablet Dashboard — управление умным домом", description: "Smart Home Tablet Dashboard",
manifest: "/manifest.json", manifest: "/manifest.json",
}; };
@@ -26,6 +26,7 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head> </head>
<body className="antialiased">{children}</body> <body className="antialiased">{children}</body>
</html> </html>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Thermometer, Droplets, Wind, Calendar, Sun, CloudRain, Snowflake as SnowIcon, Cloud, CloudSun, Zap, Settings as SettingsIcon } from 'lucide-react'
import Sidebar from '@/components/Sidebar' import Sidebar from '@/components/Sidebar'
import TopBar from '@/components/TopBar' import TopBar from '@/components/TopBar'
import RoomTabs from '@/components/RoomTabs' import RoomTabs from '@/components/RoomTabs'
@@ -56,53 +57,19 @@ const DEVICES_BY_ROOM: Record<string, {
isMock?: boolean isMock?: boolean
}[]> = { }[]> = {
living: [ living: [
{ { id: 'air_purifier', name: 'Очиститель воздуха', icon: '💨', entityId: 'fan.zhimi_rmb1_9528_air_purifier', domain: 'fan', haKey: 'fan.air_purifier', isMock: false },
id: 'air_purifier', { id: 'light_living', name: 'Свет', icon: '💡', entityId: 'light.living_room', domain: 'light', haKey: 'light.living_room', isMock: true },
name: 'Очиститель воздуха', { id: 'tv', name: 'Телевизор', icon: '📺', isMock: true },
icon: '💨',
entityId: 'fan.zhimi_rmb1_9528_air_purifier',
domain: 'fan',
haKey: 'fan.air_purifier',
isMock: false,
},
{
id: 'light_living',
name: 'Свет',
icon: '💡',
entityId: 'light.living_room',
domain: 'light',
haKey: 'light.living_room',
isMock: true,
},
{
id: 'tv',
name: 'Телевизор',
icon: '📺',
isMock: true,
},
], ],
bedroom: [ bedroom: [
{ { id: 'light_bedroom', name: 'Свет', icon: '💡', entityId: 'light.bedroom', domain: 'light', haKey: 'light.bedroom', isMock: true },
id: 'light_bedroom', { id: 'ac', name: 'Кондиционер', icon: '❄️', isMock: true },
name: 'Свет',
icon: '💡',
entityId: 'light.bedroom',
domain: 'light',
haKey: 'light.bedroom',
isMock: true,
},
{
id: 'ac',
name: 'Кондиционер',
icon: '❄️',
isMock: true,
},
], ],
kitchen: [], kitchen: [],
bathroom: [], bathroom: [],
} }
function getWeatherEmoji(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('облач')) return '⛅'
@@ -118,6 +85,13 @@ function formatEventTime(iso: string): string {
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
} }
function getPm25Level(pm25: number): { label: string; color: string; bg: string } {
if (pm25 <= 12) return { label: 'Отлично', color: '#34d399', bg: 'rgba(52,211,153,0.12)' }
if (pm25 <= 35) return { label: 'Хорошо', color: '#a3e635', bg: 'rgba(163,230,53,0.12)' }
if (pm25 <= 55) return { label: 'Умеренно', color: '#fbbf24', bg: 'rgba(251,191,36,0.12)' }
return { label: 'Плохо', color: '#f87171', bg: 'rgba(248,113,113,0.12)' }
}
// ————— Home Tab ————— // ————— Home Tab —————
function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) { function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) {
const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([]) const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([])
@@ -131,71 +105,46 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
.finally(() => setCalLoading(false)) .finally(() => setCalLoading(false))
}, []) }, [])
const pm25Info = sensors ? getPm25Level(sensors.pm25) : null
return ( return (
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Today Widget */} {/* Top row: Weather + Sensors side by side */}
<div style={{ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 20,
padding: '18px 20px',
}}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
📅 Сегодня
</div>
{calLoading ? ( {/* Weather Card */}
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Загрузка...</div>
) : todayEvents.length === 0 ? (
<div style={{ fontSize: 15, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>
Свободный день 🎉
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{todayEvents.map(ev => (
<div key={ev.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 32, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.title}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2, display: 'flex', gap: 6 }}>
<span>{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)}${formatEventTime(ev.end)}`}</span>
<span style={{ color: ev.color }}>{ev.ownerName}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Weather Widget */}
{weather && ( {weather && (
<div style={{ <div style={{
background: 'var(--card-bg)', background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.06))',
border: '1px solid var(--card-border)', backdropFilter: 'blur(20px)',
borderRadius: 20, border: '1px solid rgba(129,140,248,0.12)',
padding: '18px 20px', borderRadius: 22,
padding: '22px 24px',
position: 'relative',
overflow: 'hidden',
}}> }}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}> {/* Background decoration */}
🌤 Погода <div style={{
position: 'absolute', top: -20, right: -10,
fontSize: 80, opacity: 0.12, pointerEvents: 'none',
}}>
{getWeatherIcon(weather.desc)}
</div> </div>
{/* Current */} <div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}> Погода
<span style={{ fontSize: 44 }}>{getWeatherEmoji(weather.desc)}</span> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18, position: 'relative', zIndex: 1 }}>
<span style={{ fontSize: 44 }}>{getWeatherIcon(weather.desc)}</span>
<div> <div>
<div style={{ fontSize: 32, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{weather.temp}°C</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 }}>{weather.desc}</div> <div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>
💧 {weather.humidity}% · 💨 {weather.windSpeed} км/ч · Ощущается {weather.feelsLike}°
</div>
</div> </div>
</div> </div>
{/* Forecast */} {/* Forecast mini */}
{weather.forecast && weather.forecast.length > 0 && ( {weather.forecast && weather.forecast.length > 0 && (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
{weather.forecast.slice(0, 3).map(day => { {weather.forecast.slice(0, 3).map(day => {
@@ -205,14 +154,14 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
<div key={day.date} style={{ <div key={day.date} style={{
flex: 1, flex: 1,
background: 'rgba(255,255,255,0.04)', background: 'rgba(255,255,255,0.04)',
borderRadius: 12, borderRadius: 14,
padding: '10px 8px', padding: '10px 8px',
textAlign: 'center', textAlign: 'center',
border: '1px solid rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.04)',
}}> }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>{label}</div> <div style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4, fontWeight: 500 }}>{label}</div>
<div style={{ fontSize: 20, marginBottom: 4 }}>{getWeatherEmoji(day.desc)}</div> <div style={{ fontSize: 20, marginBottom: 4 }}>{getWeatherIcon(day.desc)}</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>{day.maxTemp}°</div> <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{day.minTemp}°</div> <div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
</div> </div>
) )
@@ -222,39 +171,124 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
</div> </div>
)} )}
{/* Sensors Widget */} {/* Sensors Card */}
{sensors && ( {sensors && (
<div style={{ <div style={{
background: 'var(--card-bg)', background: 'rgba(255,255,255,0.03)',
border: '1px solid var(--card-border)', backdropFilter: 'blur(20px)',
borderRadius: 20,
padding: '18px 20px',
}}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
📊 Датчики квартиры
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
{[
{ label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' },
{ label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' },
{ label: 'PM2.5', value: `${sensors.pm25} μg`, icon: '💨' },
].map(s => (
<div key={s.label} style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14, borderRadius: 22,
padding: '12px 10px', padding: '22px 24px',
textAlign: 'center',
}}> }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>{s.icon}</div> <div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{s.value}</div> Климат в квартире
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>{s.label}</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Temperature */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 48, height: 48, borderRadius: 16,
background: 'linear-gradient(135deg, rgba(251,146,60,0.15), rgba(245,158,11,0.08))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Thermometer size={22} color="#fb923c" />
</div>
<div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{sensors.temperature}°C</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Температура</div>
</div>
</div>
{/* Humidity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 48, height: 48, borderRadius: 16,
background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(99,102,241,0.08))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Droplets size={22} color="#3b82f6" />
</div>
<div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{sensors.humidity}%</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Влажность</div>
</div>
</div>
{/* PM2.5 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 48, height: 48, borderRadius: 16,
background: pm25Info?.bg || 'rgba(255,255,255,0.05)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Wind size={22} color={pm25Info?.color || '#999'} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{sensors.pm25}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>µg/m³</span>
</div>
<div style={{ fontSize: 12, color: pm25Info?.color, marginTop: 2, fontWeight: 500 }}>PM2.5 · {pm25Info?.label}</div>
</div>
</div> </div>
))}
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Today Events */}
<div style={{
background: 'rgba(255,255,255,0.03)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 22,
padding: '22px 24px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Calendar size={15} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600 }}>
Сегодня
</span>
</div>
{calLoading ? (
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Загрузка...</div>
) : todayEvents.length === 0 ? (
<div style={{ fontSize: 15, color: 'var(--text-secondary)', textAlign: 'center', padding: '12px 0' }}>
Нет событий на сегодня
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{todayEvents.map(ev => (
<div key={ev.id} style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '12px 16px',
borderRadius: 14,
background: `${ev.color}0a`,
border: `1px solid ${ev.color}18`,
}}>
<div style={{
width: 4, borderRadius: 2,
background: ev.color,
alignSelf: 'stretch', minHeight: 36, flexShrink: 0,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.title}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 3, display: 'flex', gap: 8 }}>
<span>{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)}${formatEventTime(ev.end)}`}</span>
<span style={{ color: ev.color, fontWeight: 500 }}>{ev.ownerName}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
) )
} }
@@ -301,9 +335,7 @@ export default function HomePage() {
} }
const getDeviceExtra = (id: string): string | undefined => { const getDeviceExtra = (id: string): string | undefined => {
if (id === 'air_purifier' && sensors) { if (id === 'air_purifier' && sensors) return `PM2.5: ${sensors.pm25}`
return `PM2.5: ${sensors.pm25}`
}
return undefined return undefined
} }
@@ -314,7 +346,11 @@ export default function HomePage() {
width: '100%', width: '100%',
background: 'var(--bg)', background: 'var(--bg)',
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}}> }}>
{/* Ambient background */}
<div className="bg-ambient" />
<Sidebar active={tab} onChange={setTab} /> <Sidebar active={tab} onChange={setTab} />
<main style={{ <main style={{
@@ -323,6 +359,8 @@ export default function HomePage() {
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
minWidth: 0, minWidth: 0,
position: 'relative',
zIndex: 1,
}}> }}>
<TopBar weather={weather} sensors={sensors} /> <TopBar weather={weather} sensors={sensors} />
@@ -335,7 +373,7 @@ export default function HomePage() {
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
WebkitOverflowScrolling: 'touch' as any, WebkitOverflowScrolling: 'touch' as any,
padding: '16px 20px 24px', padding: '16px 24px 28px',
}}> }}>
{devicesInRoom.length === 0 ? ( {devicesInRoom.length === 0 ? (
<div style={{ <div style={{
@@ -343,15 +381,20 @@ export default function HomePage() {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: 200, height: 220,
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
gap: 8, gap: 12,
}}> }}>
<span style={{ fontSize: 40 }}>🏠</span> <div style={{
<span style={{ fontSize: 15 }}>Устройства не добавлены</span> width: 64, height: 64, borderRadius: 20,
background: 'rgba(255,255,255,0.04)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 28,
}}>🏠</div>
<span style={{ fontSize: 15, fontWeight: 500 }}>Устройства не добавлены</span>
</div> </div>
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 14 }}>
{devicesInRoom.map(device => ( {devicesInRoom.map(device => (
<DeviceCard <DeviceCard
key={device.id} key={device.id}
@@ -380,11 +423,18 @@ export default function HomePage() {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 12, gap: 16,
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
}}> }}>
<span style={{ fontSize: 48 }}></span> <div style={{
<span style={{ fontSize: 16 }}>Настройки</span> width: 72, height: 72, borderRadius: 22,
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.08))',
border: '1px solid rgba(129,140,248,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<SettingsIcon size={32} color="#818cf8" />
</div>
<span style={{ fontSize: 18, fontWeight: 600 }}>Настройки</span>
<span style={{ fontSize: 13 }}>Скоро</span> <span style={{ fontSize: 13 }}>Скоро</span>
</div> </div>
)} )}

View File

@@ -27,18 +27,24 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const inputStyle = {
padding: '12px 16px',
borderRadius: 14,
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-primary)',
fontSize: 14,
outline: 'none',
fontFamily: 'inherit',
transition: 'border-color 0.2s ease',
}
const save = async () => { const save = async () => {
if (!title.trim()) { setError('Введите название'); return } if (!title.trim()) { setError('Введите название'); return }
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
const body = { const body = { title: title.trim(), date, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, allDay }
title: title.trim(),
date,
startTime: allDay ? null : startTime,
endTime: allDay ? null : endTime,
allDay,
}
const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json() const d = await r.json()
if (d.error) throw new Error(d.error) if (d.error) throw new Error(d.error)
@@ -50,31 +56,20 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
} }
return ( return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 340, width: '100%' }} onClick={e => e.stopPropagation()}> <div style={{ background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(40px)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 24, padding: 28, maxWidth: 360, width: '100%', boxShadow: '0 25px 60px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 24 }}>
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span> <span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}><X size={18} /></button> <button onClick={onClose} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Название события" autoFocus style={inputStyle} />
value={title} <input type="date" value={date} onChange={e => setDate(e.target.value)} style={inputStyle} />
onChange={e => setTitle(e.target.value)}
placeholder="Название события"
autoFocus
style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }}
/>
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }}
/>
{!allDay && ( {!allDay && (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} /> <input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} /> <input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
</div> </div>
)} )}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13, cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13, cursor: 'pointer' }}>
@@ -85,7 +80,17 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
<button <button
onClick={save} onClick={save}
disabled={saving} disabled={saving}
style={{ padding: '11px', borderRadius: 12, background: saving ? 'rgba(99,102,241,0.3)' : 'rgba(99,102,241,0.5)', border: '1px solid rgba(99,102,241,0.5)', color: '#a5b4fc', fontSize: 14, fontWeight: 600, cursor: saving ? 'default' : 'pointer', touchAction: 'manipulation' }} style={{
padding: '13px',
borderRadius: 14,
background: saving ? 'rgba(99,102,241,0.2)' : 'linear-gradient(135deg, rgba(99,102,241,0.4), rgba(139,92,246,0.3))',
border: '1px solid rgba(129,140,248,0.3)',
color: '#a5b4fc',
fontSize: 14,
fontWeight: 600,
cursor: saving ? 'default' : 'pointer',
transition: 'all 0.25s ease',
}}
> >
{saving ? 'Сохранение...' : 'Создать событие'} {saving ? 'Сохранение...' : 'Создать событие'}
</button> </button>
@@ -102,9 +107,9 @@ export default function CalendarTab() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null) const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [addDate, setAddDate] = useState<string>('')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [addDate, setAddDate] = useState<string>('')
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
@@ -114,7 +119,6 @@ export default function CalendarTab() {
.catch(() => setLoading(false)) .catch(() => setLoading(false))
}, [year, month]) }, [year, month])
const deleteEvent = async (event: CalendarEvent) => { const deleteEvent = async (event: CalendarEvent) => {
setDeleting(true) setDeleting(true)
try { try {
@@ -131,15 +135,12 @@ export default function CalendarTab() {
} }
} }
// Upcoming events (next 30 days)
const upcoming = events const upcoming = events
.filter(e => new Date(e.start) >= new Date()) .filter(e => new Date(e.start) >= new Date())
.slice(0, 6) .slice(0, 6)
// Build calendar grid
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0) const lastDay = new Date(year, month + 1, 0)
// Monday-based week: 0=Mon, 6=Sun
const startOffset = (firstDay.getDay() + 6) % 7 const startOffset = (firstDay.getDay() + 6) % 7
const totalCells = Math.ceil((startOffset + lastDay.getDate()) / 7) * 7 const totalCells = Math.ceil((startOffset + lastDay.getDate()) / 7) * 7
const cells: (number | null)[] = [] const cells: (number | null)[] = []
@@ -161,25 +162,46 @@ export default function CalendarTab() {
const today = new Date() const today = new Date()
return ( return (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '12px 16px 16px', gap: 16 }}> <div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '16px 24px 24px', gap: 20 }}>
{/* Main calendar grid */} {/* Main calendar grid */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={prevMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <button onClick={prevMonth} style={{
width: 36, height: 36, borderRadius: 12,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
color: 'var(--text-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s ease',
}}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</button> </button>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', minWidth: 160, textAlign: 'center' }}> <span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', minWidth: 180, textAlign: 'center' }}>
{MONTHS[month]} {year} {MONTHS[month]} {year}
</span> </span>
<button onClick={nextMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <button onClick={nextMonth} style={{
width: 36, height: 36, borderRadius: 12,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
color: 'var(--text-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s ease',
}}>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
</div> </div>
<button <button
onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }} onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(99,102,241,0.2)', border: '1px solid rgba(99,102,241,0.4)', color: '#a5b4fc', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 18px', borderRadius: 14,
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
border: '1px solid rgba(129,140,248,0.25)',
color: '#a5b4fc', fontSize: 13, fontWeight: 600,
transition: 'all 0.25s ease',
}}
> >
<Plus size={15} /> <Plus size={15} />
Событие Событие
@@ -187,18 +209,24 @@ export default function CalendarTab() {
</div> </div>
{/* Weekday headers */} {/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 6 }}>
{WEEKDAYS.map(d => ( {WEEKDAYS.map((d, i) => (
<div key={d} style={{ textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', padding: '4px 0' }}>{d}</div> <div key={d} style={{
textAlign: 'center', fontSize: 11, fontWeight: 600,
color: i >= 5 ? 'rgba(248,113,113,0.5)' : 'var(--text-secondary)',
textTransform: 'uppercase', padding: '6px 0', letterSpacing: '0.05em',
}}>{d}</div>
))} ))}
</div> </div>
{/* Calendar cells */} {/* Calendar cells */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 2 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 3 }}>
{cells.map((day, idx) => { {cells.map((day, idx) => {
if (!day) return <div key={idx} /> if (!day) return <div key={idx} />
const dayEvents = getEventsForDay(day) const dayEvents = getEventsForDay(day)
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day
const dayOfWeek = (startOffset + day - 1) % 7
const isWeekend = dayOfWeek >= 5
return ( return (
<div <div
key={idx} key={idx}
@@ -210,34 +238,40 @@ export default function CalendarTab() {
} }
}} }}
style={{ style={{
borderRadius: 8, borderRadius: 12,
padding: '4px 3px', padding: '5px 4px',
background: isToday ? 'rgba(99,102,241,0.12)' : 'rgba(255,255,255,0.02)', background: isToday
border: isToday ? '1px solid rgba(99,102,241,0.35)' : '1px solid transparent', ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))'
: 'rgba(255,255,255,0.015)',
border: isToday
? '1px solid rgba(129,140,248,0.3)'
: '1px solid transparent',
cursor: 'pointer', cursor: 'pointer',
minHeight: 52, minHeight: 56,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 2, gap: 2,
touchAction: 'manipulation', transition: 'all 0.2s ease',
}} }}
> >
<span style={{ fontSize: 12, fontWeight: isToday ? 700 : 400, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', textAlign: 'right', paddingRight: 3 }}>{day}</span> <span style={{
fontSize: 12,
fontWeight: isToday ? 700 : 500,
color: isToday ? '#a5b4fc' : isWeekend ? 'rgba(248,113,113,0.6)' : 'var(--text-secondary)',
textAlign: 'right', paddingRight: 4,
}}>{day}</span>
{dayEvents.slice(0, 2).map(e => ( {dayEvents.slice(0, 2).map(e => (
<div <div
key={e.id} key={e.id}
onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }} onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }}
style={{ style={{
fontSize: 10, fontSize: 10, fontWeight: 600,
fontWeight: 600, background: e.color + '1a',
background: e.color + '33', border: `1px solid ${e.color}30`,
border: `1px solid ${e.color}55`,
color: e.color, color: e.color,
borderRadius: 4, borderRadius: 6,
padding: '1px 4px', padding: '2px 5px',
overflow: 'hidden', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
@@ -254,10 +288,18 @@ export default function CalendarTab() {
</div> </div>
{/* Right panel: upcoming events */} {/* Right panel: upcoming events */}
<div style={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto' }}> <div style={{
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Ближайшие</div> width: 220, flexShrink: 0,
display: 'flex', flexDirection: 'column', gap: 10,
overflowY: 'auto',
background: 'rgba(255,255,255,0.02)',
borderRadius: 22,
padding: '20px 16px',
border: '1px solid rgba(255,255,255,0.04)',
}}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Ближайшие</div>
{upcoming.length === 0 && !loading && ( {upcoming.length === 0 && !loading && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Нет событий</div> <div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: '20px 0', textAlign: 'center' }}>Нет событий</div>
)} )}
{upcoming.map(e => { {upcoming.map(e => {
const d = new Date(e.start) const d = new Date(e.start)
@@ -266,25 +308,29 @@ export default function CalendarTab() {
key={e.id} key={e.id}
onClick={() => setSelectedEvent(e)} onClick={() => setSelectedEvent(e)}
style={{ style={{
borderRadius: 12, borderRadius: 16,
padding: '10px 12px', padding: '14px 14px',
background: e.color + '18', background: `${e.color}0c`,
border: `1px solid ${e.color}33`, border: `1px solid ${e.color}1a`,
cursor: 'pointer', cursor: 'pointer',
touchAction: 'manipulation', transition: 'all 0.25s ease',
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: e.color + '33', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{
width: 32, height: 32, borderRadius: 10,
background: `${e.color}1a`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span> <span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
</div> </div>
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div> <div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
</div> </div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div> <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}> <div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 4 }}>
{e.allDay ? 'Весь день' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} {e.allDay ? 'Весь день' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</div> </div>
<div style={{ fontSize: 10, color: e.color, marginTop: 3, fontWeight: 500 }}>{e.ownerName}</div> <div style={{ fontSize: 10, color: e.color, marginTop: 4, fontWeight: 500 }}>{e.ownerName}</div>
</div> </div>
) )
})} })}
@@ -292,38 +338,66 @@ export default function CalendarTab() {
{/* Event detail modal */} {/* Event detail modal */}
{selectedEvent && ( {selectedEvent && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }}>
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}> <div style={{
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}> background: 'rgba(18,18,35,0.95)',
<div style={{ width: 4, height: 40, borderRadius: 2, background: selectedEvent.color, marginRight: 12, flexShrink: 0, marginTop: 2 }} /> backdropFilter: 'blur(40px)',
<div style={{ flex: 1 }}> border: '1px solid rgba(255,255,255,0.08)',
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedEvent.title}</div> borderRadius: 24, padding: 28, maxWidth: 380, width: '100%',
<div style={{ fontSize: 12, color: selectedEvent.color, fontWeight: 500, marginTop: 2 }}>{selectedEvent.ownerName}</div> boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div style={{ display: 'flex', gap: 14 }}>
<div style={{ width: 4, borderRadius: 3, background: selectedEvent.color, alignSelf: 'stretch', minHeight: 44, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedEvent.title}</div>
<div style={{ fontSize: 13, color: selectedEvent.color, fontWeight: 500, marginTop: 3 }}>{selectedEvent.ownerName}</div>
</div> </div>
<button onClick={() => setSelectedEvent(null)} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 4 }}><X size={18} /></button>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <button onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}> </div>
<Clock size={14} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.03)',
color: 'var(--text-secondary)', fontSize: 13,
}}>
<Clock size={15} />
{selectedEvent.allDay {selectedEvent.allDay
? 'Весь день' ? 'Весь день'
: `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` : `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
} }
</div> </div>
{selectedEvent.location && ( {selectedEvent.location && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}> <div style={{
<MapPin size={14} /> {selectedEvent.location} display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.03)',
color: 'var(--text-secondary)', fontSize: 13,
}}>
<MapPin size={15} /> {selectedEvent.location}
</div> </div>
)} )}
{selectedEvent.description && ( {selectedEvent.description && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.5 }}>{selectedEvent.description}</div> <div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.6 }}>{selectedEvent.description}</div>
)} )}
{/* Delete button */} {/* Delete button */}
<div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 12 }}> <div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 14 }}>
{!confirmDelete ? ( {!confirmDelete ? (
<button <button
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', color: '#f87171', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation', width: '100%', justifyContent: 'center' }} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px', borderRadius: 12,
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)',
color: '#f87171', fontSize: 13, fontWeight: 600,
width: '100%', justifyContent: 'center',
transition: 'all 0.25s ease',
}}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Удалить событие Удалить событие
@@ -333,13 +407,26 @@ export default function CalendarTab() {
<button <button
onClick={() => deleteEvent(selectedEvent)} onClick={() => deleteEvent(selectedEvent)}
disabled={deleting} disabled={deleting}
style={{ flex: 1, padding: '8px 14px', borderRadius: 10, background: deleting ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.25)', border: '1px solid rgba(239,68,68,0.5)', color: '#f87171', fontSize: 13, fontWeight: 600, cursor: deleting ? 'default' : 'pointer', touchAction: 'manipulation' }} style={{
flex: 1, padding: '10px 14px', borderRadius: 12,
background: deleting ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.35)',
color: '#f87171', fontSize: 13, fontWeight: 600,
cursor: deleting ? 'default' : 'pointer',
transition: 'all 0.25s ease',
}}
> >
{deleting ? 'Удаление...' : 'Да, удалить'} {deleting ? 'Удаление...' : 'Да, удалить'}
</button> </button>
<button <button
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
style={{ flex: 1, padding: '8px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }} style={{
flex: 1, padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600,
transition: 'all 0.25s ease',
}}
> >
Отмена Отмена
</button> </button>
@@ -351,7 +438,6 @@ export default function CalendarTab() {
</div> </div>
)} )}
{/* Add event modal */}
{showAddModal && ( {showAddModal && (
<AddEventModal <AddEventModal
defaultDate={addDate} defaultDate={addDate}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Fan, Lightbulb, Tv, Snowflake, Power } from 'lucide-react'
interface DeviceCardProps { interface DeviceCardProps {
id: string id: string
@@ -13,6 +14,32 @@ interface DeviceCardProps {
extraInfo?: string extraInfo?: string
} }
function getDeviceIcon(id: string, isOn: boolean) {
const color = isOn ? '#a5b4fc' : 'rgba(255,255,255,0.35)'
const size = 24
if (id.includes('air_purifier')) return <Fan size={size} color={color} />
if (id.includes('light')) return <Lightbulb size={size} color={isOn ? '#fbbf24' : color} />
if (id.includes('tv')) return <Tv size={size} color={color} />
if (id.includes('ac')) return <Snowflake size={size} color={isOn ? '#22d3ee' : color} />
return <Power size={size} color={color} />
}
function getDeviceGradient(id: string): string {
if (id.includes('air_purifier')) return 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.12))'
if (id.includes('light')) return 'linear-gradient(135deg, rgba(251,191,36,0.18), rgba(245,158,11,0.1))'
if (id.includes('tv')) return 'linear-gradient(135deg, rgba(59,130,246,0.18), rgba(99,102,241,0.1))'
if (id.includes('ac')) return 'linear-gradient(135deg, rgba(34,211,238,0.18), rgba(6,182,212,0.1))'
return 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))'
}
function getDeviceBorder(id: string): string {
if (id.includes('air_purifier')) return 'rgba(129,140,248,0.25)'
if (id.includes('light')) return 'rgba(251,191,36,0.25)'
if (id.includes('tv')) return 'rgba(99,102,241,0.25)'
if (id.includes('ac')) return 'rgba(34,211,238,0.25)'
return 'rgba(129,140,248,0.2)'
}
export default function DeviceCard({ export default function DeviceCard({
id, name, icon, entityId, domain, initialState, isMock = false, extraInfo, id, name, icon, entityId, domain, initialState, isMock = false, extraInfo,
}: DeviceCardProps) { }: DeviceCardProps) {
@@ -20,7 +47,6 @@ export default function DeviceCard({
const [synced, setSynced] = useState(false) const [synced, setSynced] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// Sync from HA only once — when real data first arrives (not mock default)
useEffect(() => { useEffect(() => {
if (!synced && !isMock) { if (!synced && !isMock) {
setIsOn(initialState) setIsOn(initialState)
@@ -30,7 +56,7 @@ export default function DeviceCard({
const toggle = async () => { const toggle = async () => {
const next = !isOn const next = !isOn
setIsOn(next) // optimistic setIsOn(next)
if (!isMock && entityId && domain) { if (!isMock && entityId && domain) {
setLoading(true) setLoading(true)
@@ -52,69 +78,98 @@ export default function DeviceCard({
} }
} }
const accent = '#00d4ff'
return ( return (
<div style={{ <div style={{
background: isOn ? 'rgba(0,212,255,0.07)' : 'rgba(255,255,255,0.04)', background: isOn ? getDeviceGradient(id) : 'rgba(255,255,255,0.03)',
border: isOn ? '1px solid rgba(0,212,255,0.25)' : '1px solid rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)',
borderRadius: 18, WebkitBackdropFilter: 'blur(20px)',
padding: '18px 16px 16px', border: isOn ? `1px solid ${getDeviceBorder(id)}` : '1px solid rgba(255,255,255,0.06)',
minHeight: 140, borderRadius: 22,
padding: '20px 18px 18px',
minHeight: 150,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'space-between',
transition: 'background 0.25s ease, border-color 0.25s ease', transition: 'all 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: isOn ? '0 0 24px rgba(0,212,255,0.06)' : 'none', boxShadow: isOn ? '0 8px 32px rgba(99,102,241,0.1)' : '0 2px 8px rgba(0,0,0,0.1)',
position: 'relative',
overflow: 'hidden',
}}> }}>
{/* Top: icon + toggle */} {/* Subtle glow effect when on */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}> {isOn && (
<div style={{ <div style={{
width: 42, height: 42, borderRadius: 12, position: 'absolute',
background: isOn ? 'rgba(0,212,255,0.15)' : 'rgba(255,255,255,0.06)', top: -30,
right: -30,
width: 100,
height: 100,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(129,140,248,0.15) 0%, transparent 70%)',
pointerEvents: 'none',
}} />
)}
{/* Top: icon + toggle */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
<div style={{
width: 48, height: 48, borderRadius: 16,
background: isOn ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.05)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, transition: 'all 0.3s ease',
transition: 'background 0.25s ease', boxShadow: isOn ? '0 4px 16px rgba(0,0,0,0.1)' : 'none',
boxShadow: isOn ? '0 0 16px rgba(0,212,255,0.25)' : 'none',
}}> }}>
{icon} {getDeviceIcon(id, isOn)}
</div> </div>
<button <button
onClick={toggle} onClick={toggle}
disabled={loading} disabled={loading}
style={{ style={{
width: 52, height: 30, borderRadius: 15, width: 54, height: 30, borderRadius: 15,
background: isOn ? `linear-gradient(90deg, #00b4d8, #00d4ff)` : 'rgba(255,255,255,0.1)', background: isOn
? 'linear-gradient(90deg, #6366f1, #8b5cf6)'
: 'rgba(255,255,255,0.08)',
position: 'relative', border: 'none', cursor: 'pointer', position: 'relative', border: 'none', cursor: 'pointer',
flexShrink: 0, touchAction: 'manipulation', flexShrink: 0,
WebkitTapHighlightColor: 'transparent', transition: 'all 0.3s ease',
transition: 'background 0.25s ease',
opacity: loading ? 0.6 : 1, opacity: loading ? 0.6 : 1,
boxShadow: isOn ? '0 0 10px rgba(0,212,255,0.4)' : 'none', boxShadow: isOn ? '0 0 16px rgba(99,102,241,0.3)' : 'none',
}} }}
> >
<span style={{ <span style={{
position: 'absolute', top: 4, position: 'absolute', top: 4,
left: isOn ? 26 : 4, left: isOn ? 28 : 4,
width: 22, height: 22, borderRadius: '50%', width: 22, height: 22, borderRadius: '50%',
background: '#fff', background: '#fff',
boxShadow: '0 1px 4px rgba(0,0,0,0.3)', boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
transition: 'left 0.22s ease', transition: 'left 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'block', display: 'block',
}} /> }} />
</button> </button>
</div> </div>
{/* Bottom: name + status */} {/* Bottom: name + status */}
<div> <div style={{ position: 'relative', zIndex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3 }}> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3 }}>
{name} {name}
</div> </div>
<div style={{ fontSize: 12, color: isOn ? accent : 'var(--text-secondary)', marginTop: 3 }}> <div style={{
fontSize: 12,
color: isOn ? '#a5b4fc' : 'var(--text-secondary)',
marginTop: 4,
fontWeight: 500,
}}>
{isOn ? 'Включён' : 'Выключен'} {isOn ? 'Включён' : 'Выключен'}
{extraInfo && isOn ? ` · ${extraInfo}` : ''} {extraInfo && isOn ? ` · ${extraInfo}` : ''}
</div> </div>
{isMock && (
<div style={{
fontSize: 10,
color: 'var(--text-tertiary)',
marginTop: 2,
fontStyle: 'italic',
}}>demo</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -18,8 +18,8 @@ export default function RoomTabs({ rooms, active, onChange }: RoomTabsProps) {
<div <div
style={{ style={{
display: 'flex', display: 'flex',
gap: 10, gap: 8,
padding: '12px 20px', padding: '14px 24px',
overflowX: 'auto', overflowX: 'auto',
flexShrink: 0, flexShrink: 0,
WebkitOverflowScrolling: 'touch' as any, WebkitOverflowScrolling: 'touch' as any,
@@ -35,35 +35,39 @@ export default function RoomTabs({ rooms, active, onChange }: RoomTabsProps) {
onClick={() => onChange(room.id)} onClick={() => onChange(room.id)}
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 2, gap: 8,
padding: '10px 18px', padding: '10px 20px',
borderRadius: 14, borderRadius: 16,
background: isActive ? 'rgba(0,212,255,0.12)' : 'rgba(255,255,255,0.04)', background: isActive
border: isActive ? '1px solid rgba(0,212,255,0.3)' : '1px solid rgba(255,255,255,0.06)', ? 'linear-gradient(135deg, rgba(99,102,241,0.18), rgba(139,92,246,0.12))'
minWidth: 90, : 'rgba(255,255,255,0.03)',
border: isActive
? '1px solid rgba(129,140,248,0.25)'
: '1px solid rgba(255,255,255,0.05)',
minWidth: 'fit-content',
flexShrink: 0, flexShrink: 0,
touchAction: 'manipulation', transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
WebkitTapHighlightColor: 'transparent', boxShadow: isActive ? '0 4px 16px rgba(99,102,241,0.1)' : 'none',
transition: 'all 0.2s ease',
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 18 }}>{room.emoji}</span> <span style={{ fontSize: 18 }}>{room.emoji}</span>
<span <span
style={{ style={{
fontSize: 14, fontSize: 14,
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 500,
color: isActive ? 'var(--accent)' : 'var(--text-primary)', color: isActive ? '#a5b4fc' : 'var(--text-primary)',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{room.name} {room.name}
</span> </span>
</div> <span style={{
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}> fontSize: 11,
{room.deviceCount} {room.deviceCount === 1 ? 'устройство' : room.deviceCount >= 2 && room.deviceCount <= 4 ? 'устройства' : 'устройств'} color: isActive ? 'rgba(165,180,252,0.6)' : 'var(--text-tertiary)',
fontWeight: 500,
}}>
{room.deviceCount}
</span> </span>
</button> </button>
) )

View File

@@ -20,19 +20,19 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
return ( return (
<nav <nav
style={{ style={{
width: 72, width: 78,
minWidth: 72, minWidth: 78,
height: '100dvh', height: '100dvh',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
paddingTop: 20, paddingTop: 24,
paddingBottom: 20, paddingBottom: 24,
background: 'rgba(0,0,0,0.5)', background: 'rgba(12, 12, 24, 0.6)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(30px)',
WebkitBackdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(30px)',
borderRight: '1px solid rgba(255,255,255,0.05)', borderRight: '1px solid rgba(255,255,255,0.04)',
gap: 8, gap: 6,
flexShrink: 0, flexShrink: 0,
zIndex: 10, zIndex: 10,
}} }}
@@ -40,19 +40,20 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
{/* Logo */} {/* Logo */}
<div <div
style={{ style={{
width: 44, width: 48,
height: 44, height: 48,
borderRadius: 14, borderRadius: 16,
background: 'linear-gradient(135deg, #00d4ff22, #00d4ff44)', background: 'linear-gradient(135deg, rgba(99,102,241,0.25), rgba(139,92,246,0.25))',
border: '1px solid rgba(0,212,255,0.3)', border: '1px solid rgba(129,140,248,0.3)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 24, marginBottom: 28,
flexShrink: 0, flexShrink: 0,
boxShadow: '0 0 20px rgba(99,102,241,0.15)',
}} }}
> >
<Home size={20} color="#00d4ff" /> <Home size={22} color="#818cf8" />
</div> </div>
{/* Nav items */} {/* Nav items */}
@@ -64,19 +65,38 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
onClick={() => onChange(id)} onClick={() => onChange(id)}
title={label} title={label}
style={{ style={{
width: 48, width: 52,
height: 48, height: 52,
borderRadius: 14, borderRadius: 16,
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: isActive ? 'rgba(0,212,255,0.15)' : 'transparent', gap: 4,
border: isActive ? '1px solid rgba(0,212,255,0.25)' : '1px solid transparent', background: isActive
transition: 'all 0.2s ease', ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))'
: 'transparent',
border: isActive
? '1px solid rgba(129,140,248,0.25)'
: '1px solid transparent',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
flexShrink: 0, flexShrink: 0,
boxShadow: isActive ? '0 0 20px rgba(99,102,241,0.1)' : 'none',
}} }}
> >
<Icon size={22} color={isActive ? '#00d4ff' : 'rgba(255,255,255,0.4)'} /> <Icon
size={20}
color={isActive ? '#a5b4fc' : 'rgba(255,255,255,0.3)'}
strokeWidth={isActive ? 2.2 : 1.8}
/>
<span style={{
fontSize: 9,
fontWeight: isActive ? 600 : 500,
color: isActive ? '#a5b4fc' : 'rgba(255,255,255,0.25)',
letterSpacing: '0.02em',
}}>
{label}
</span>
</button> </button>
) )
})} })}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Cloud, Droplets, Wind } from 'lucide-react' import { Droplets, Wind, Thermometer, X } from 'lucide-react'
interface WeatherData { interface WeatherData {
temp: string temp: string
@@ -23,7 +23,7 @@ interface TopBarProps {
sensors: SensorData | null sensors: SensorData | null
} }
function getWeatherEmoji(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('облач')) return '⛅'
@@ -39,7 +39,10 @@ function formatTime(date: Date): string {
} }
function formatDate(date: Date): string { function formatDate(date: Date): string {
return date.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' }) const weekday = date.toLocaleDateString('ru-RU', { weekday: 'long' })
const day = date.getDate()
const month = date.toLocaleDateString('ru-RU', { month: 'long' })
return `${weekday}, ${day} ${month}`
} }
export default function TopBar({ weather, sensors }: TopBarProps) { export default function TopBar({ weather, sensors }: TopBarProps) {
@@ -55,59 +58,66 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
<> <>
<header <header
style={{ style={{
height: 64, height: 72,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0 20px', padding: '0 24px',
borderBottom: '1px solid rgba(255,255,255,0.06)', borderBottom: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(255,255,255,0.02)', background: 'transparent',
backdropFilter: 'blur(10px)',
flexShrink: 0, flexShrink: 0,
position: 'relative',
zIndex: 5,
}} }}
> >
{/* Left: time + date */} {/* Left: time + date */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
<span style={{ fontSize: 22, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.5px' }}> <span style={{
fontSize: 28,
fontWeight: 700,
color: 'var(--text-primary)',
letterSpacing: '-1px',
fontVariantNumeric: 'tabular-nums',
}}>
{formatTime(time)} {formatTime(time)}
</span> </span>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}> <span style={{
fontSize: 14,
color: 'var(--text-secondary)',
fontWeight: 400,
textTransform: 'capitalize',
}}>
{formatDate(time)} {formatDate(time)}
</span> </span>
</div> </div>
{/* Right: sensors + weather */} {/* Right: sensors + weather */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Room sensors */} {/* Room sensors */}
{sensors && ( {sensors && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> display: 'flex',
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>🌡</span> alignItems: 'center',
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-primary)' }}> gap: 4,
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)" />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 8 }}>
{sensors.temperature}° {sensors.temperature}°
</span> </span>
</div> <Droplets size={14} color="rgba(255,255,255,0.35)" />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 8 }}>
<Droplets size={13} color="rgba(255,255,255,0.4)" />
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-primary)' }}>
{sensors.humidity}% {sensors.humidity}%
</span> </span>
</div> <Wind size={14} color="rgba(255,255,255,0.35)" />
{sensors.pm25 !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Wind size={13} color="rgba(255,255,255,0.4)" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}> <span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
PM2.5: {sensors.pm25} {sensors.pm25}
</span> </span>
</div> </div>
)} )}
</div>
)}
{/* Divider */}
{sensors && weather && (
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.08)' }} />
)}
{/* Weather widget */} {/* Weather widget */}
{weather && ( {weather && (
@@ -116,19 +126,18 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 8,
padding: '6px 12px', padding: '8px 16px',
borderRadius: 10, borderRadius: 14,
background: 'rgba(255,255,255,0.05)', background: 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.08))',
border: '1px solid rgba(255,255,255,0.08)', border: '1px solid rgba(129,140,248,0.15)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
touchAction: 'manipulation', transition: 'all 0.25s ease',
WebkitTapHighlightColor: 'transparent',
}} }}
> >
<span style={{ fontSize: 16 }}>{getWeatherEmoji(weather.desc)}</span> <span style={{ fontSize: 20 }}>{getWeatherIcon(weather.desc)}</span>
<span style={{ fontSize: 16, fontWeight: 600 }}>{weather.temp}°</span> <span style={{ fontSize: 15, fontWeight: 700 }}>{weather.temp}°</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{weather.desc}</span> <span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{weather.desc}</span>
</button> </button>
)} )}
</div> </div>
@@ -142,7 +151,7 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
background: 'rgba(0,0,0,0.7)', background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(8px)', backdropFilter: 'blur(12px)',
zIndex: 100, zIndex: 100,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -152,75 +161,84 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
<div <div
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
style={{ style={{
background: '#13131f', background: 'rgba(18, 18, 35, 0.95)',
border: '1px solid rgba(255,255,255,0.1)', backdropFilter: 'blur(40px)',
borderRadius: 20, border: '1px solid rgba(255,255,255,0.08)',
padding: 28, borderRadius: 24,
minWidth: 320, padding: 32,
maxWidth: 400, minWidth: 360,
maxWidth: 420,
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<span style={{ fontSize: 40 }}>{getWeatherEmoji(weather.desc)}</span> <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ fontSize: 48 }}>{getWeatherIcon(weather.desc)}</span>
<div> <div>
<div style={{ fontSize: 36, fontWeight: 700, lineHeight: 1 }}>{weather.temp}°C</div> <div style={{ fontSize: 40, fontWeight: 800, lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
<div style={{ fontSize: 15, color: 'var(--text-secondary)', marginTop: 2 }}>{weather.desc}</div> <div style={{ fontSize: 15, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
</div> </div>
</div> </div>
<button onClick={() => setShowModal(false)} style={{ color: 'var(--text-secondary)', padding: 4 }}>
<X size={20} />
</button>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 20 }}> <div style={{
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}> display: 'grid',
<Droplets size={13} style={{ marginRight: 4 }} /> gridTemplateColumns: 'repeat(3, 1fr)',
Влажность: {weather.humidity}% gap: 10,
</div> marginBottom: 24,
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}> }}>
<Wind size={13} style={{ marginRight: 4 }} /> {[
Ветер: {weather.windSpeed} км/ч { icon: <Droplets size={16} />, label: 'Влажность', value: `${weather.humidity}%` },
</div> { icon: <Wind size={16} />, label: 'Ветер', value: `${weather.windSpeed} км/ч` },
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}> { icon: <Thermometer size={16} />, label: 'Ощущается', value: `${weather.feelsLike}°` },
Ощущается: {weather.feelsLike}° ].map(item => (
<div key={item.label} style={{
padding: '14px 12px',
borderRadius: 16,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
textAlign: 'center',
}}>
<div style={{ color: 'var(--text-secondary)', marginBottom: 8, display: 'flex', justifyContent: 'center' }}>{item.icon}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{item.value}</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 4 }}>{item.label}</div>
</div> </div>
))}
</div> </div>
{weather.forecast && weather.forecast.length > 0 && ( {weather.forecast && weather.forecast.length > 0 && (
<div> <>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12 }}> <div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 14 }}>
Прогноз Прогноз
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{weather.forecast.map(day => { {weather.forecast.map(day => {
const d = new Date(day.date) const d = new Date(day.date)
const label = d.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' }) const label = d.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' })
return ( return (
<div key={day.date} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div key={day.date} style={{
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>{label}</span> display: 'flex',
<span style={{ fontSize: 16 }}>{getWeatherEmoji(day.desc)}</span> alignItems: 'center',
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{day.desc}</span> justifyContent: 'space-between',
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}> padding: '10px 14px',
{day.maxTemp}° / {day.minTemp}° borderRadius: 12,
background: 'rgba(255,255,255,0.03)',
}}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100, textTransform: 'capitalize' }}>{label}</span>
<span style={{ fontSize: 18 }}>{getWeatherIcon(day.desc)}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', minWidth: 80, textAlign: 'center' }}>{day.desc}</span>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
{day.maxTemp}° <span style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>/ {day.minTemp}°</span>
</span> </span>
</div> </div>
) )
})} })}
</div> </div>
</div> </>
)} )}
<button
onClick={() => setShowModal(false)}
style={{
marginTop: 20,
width: '100%',
padding: '10px',
borderRadius: 10,
background: 'rgba(255,255,255,0.05)',
color: 'var(--text-secondary)',
fontSize: 14,
touchAction: 'manipulation',
}}
>
Закрыть
</button>
</div> </div>
</div> </div>
)} )}