All checks were successful
Deploy / deploy (push) Successful in 3m10s
Adds a live transit widget on the home screen showing upcoming trams
at both directions of the stop: toward Новочеркасская (stopID 16226)
and toward пр. Большевиков (stopID 16354).
- /api/transport proxies the СПб ORGP endpoint /stop/{id}/arriving
(DataTables POST format, JSON response with route number + minutes).
No auth required, free.
- TransportWidget renders two glassmorphism cards with route badges,
minutes-to-arrival, wheelchair indicator; imminent (<=2 min) arrivals
get a colored highlight. Filters to trams 23/27/39; refreshes every 30s.
- Route colors: 23 blue, 27 amber, 39 purple.
1004 lines
46 KiB
TypeScript
1004 lines
46 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { motion, AnimatePresence } from 'framer-motion'
|
||
import { Thermometer, Droplets, Wind, Calendar, Lock, Settings as SettingsIcon, LogOut, Delete, KeyRound, MapPin, Info, Check, X as XIcon, StickyNote, ShoppingCart, FileText } from 'lucide-react'
|
||
import Sidebar from '@/components/Sidebar'
|
||
import TopBar from '@/components/TopBar'
|
||
import RoomTabs from '@/components/RoomTabs'
|
||
import DeviceCard from '@/components/DeviceCard'
|
||
import CalendarTab from '@/components/CalendarTab'
|
||
import NotesTab from '@/components/NotesTab'
|
||
import TransportWidget from '@/components/TransportWidget'
|
||
import WeatherAnimation from '@/components/WeatherAnimation'
|
||
|
||
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
||
|
||
interface WeatherData {
|
||
temp: string
|
||
desc: string
|
||
humidity: string
|
||
windSpeed: string
|
||
feelsLike: string
|
||
forecast?: { date: string; maxTemp: string; minTemp: string; desc: string; feelsLikeMax?: string; feelsLikeMin?: string; precipProb?: string; windSpeed?: string; humidity?: string }[]
|
||
}
|
||
|
||
interface SensorData {
|
||
temperature: number
|
||
humidity: number
|
||
pm25: number
|
||
}
|
||
|
||
interface HaStates {
|
||
[key: string]: { state: string; attributes?: Record<string, any>; _mock?: boolean }
|
||
}
|
||
|
||
interface CalendarEvent {
|
||
id: string
|
||
title: string
|
||
start: string
|
||
end: string
|
||
allDay: boolean
|
||
owner: string
|
||
ownerName: string
|
||
color: string
|
||
}
|
||
|
||
const ROOMS = [
|
||
{ id: 'living', name: 'Гостиная', emoji: '🛋️', deviceCount: 3 },
|
||
{ id: 'bedroom', name: 'Спальня', emoji: '🛏️', deviceCount: 2 },
|
||
{ id: 'kitchen', name: 'Кухня', emoji: '🍳', deviceCount: 0 },
|
||
{ id: 'bathroom', name: 'Ванная', emoji: '🚿', deviceCount: 0 },
|
||
]
|
||
|
||
const DEVICES_BY_ROOM: Record<string, {
|
||
id: string; name: string; icon: string
|
||
entityId?: string; domain?: string; haKey?: string; isMock?: boolean
|
||
}[]> = {
|
||
living: [
|
||
{ id: 'air_purifier', name: 'Очиститель воздуха', 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: [
|
||
{ id: 'light_bedroom', name: 'Свет', icon: '💡', entityId: 'light.bedroom', domain: 'light', haKey: 'light.bedroom', isMock: true },
|
||
{ id: 'ac', name: 'Кондиционер', icon: '❄️', isMock: true },
|
||
],
|
||
kitchen: [],
|
||
bathroom: [],
|
||
}
|
||
|
||
const CITIES = [
|
||
{ id: 'spb', name: 'Санкт-Петербург', lat: '59.9343', lon: '30.3351' },
|
||
{ id: 'msk', name: 'Москва', lat: '55.7558', lon: '37.6173' },
|
||
{ id: 'nsk', name: 'Новосибирск', lat: '55.0084', lon: '82.9357' },
|
||
{ id: 'ekb', name: 'Екатеринбург', lat: '56.8389', lon: '60.6057' },
|
||
{ id: 'kzn', name: 'Казань', lat: '55.7887', lon: '49.1221' },
|
||
{ id: 'sochi', name: 'Сочи', lat: '43.5855', lon: '39.7231' },
|
||
{ id: 'krd', name: 'Краснодар', lat: '45.0355', lon: '38.9753' },
|
||
]
|
||
|
||
function getWeatherIcon(desc: string): string {
|
||
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('морос') || d.includes('ливен')) return '🌧️'
|
||
if (d.includes('снег')) return '🌨️'
|
||
if (d.includes('гроз')) return '⛈️'
|
||
if (d.includes('туман')) return '🌫️'
|
||
return '🌤️'
|
||
}
|
||
|
||
function formatEventTime(iso: string): string {
|
||
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 {
|
||
const h = new Date().getHours()
|
||
if (h >= 5 && h < 12) return 'Доброе утро'
|
||
if (h >= 12 && h < 17) return 'Добрый день'
|
||
if (h >= 17 && h < 22) return 'Добрый вечер'
|
||
return 'Доброй ночи'
|
||
}
|
||
|
||
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)' }
|
||
}
|
||
|
||
// ————— Screensaver —————
|
||
function Screensaver({ weather, onDismiss }: { weather: WeatherData | null; onDismiss: () => void }) {
|
||
const [time, setTime] = useState(new Date())
|
||
|
||
useEffect(() => {
|
||
const t = setInterval(() => setTime(new Date()), 1000)
|
||
return () => clearInterval(t)
|
||
}, [])
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.8 }}
|
||
onClick={onDismiss}
|
||
onTouchStart={onDismiss}
|
||
style={{
|
||
position: 'fixed', inset: 0, zIndex: 200,
|
||
background: '#050510',
|
||
display: 'flex', flexDirection: 'column',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', gap: 24,
|
||
}}
|
||
>
|
||
{/* Subtle ambient */}
|
||
<div style={{
|
||
position: 'absolute', width: 400, height: 400, borderRadius: '50%',
|
||
background: 'radial-gradient(circle, rgba(99,102,241,0.06) 0%, transparent 70%)',
|
||
animation: 'float1 20s ease-in-out infinite',
|
||
}} />
|
||
|
||
{/* Time */}
|
||
<div style={{
|
||
fontSize: 120, fontWeight: 800, color: 'rgba(255,255,255,0.9)',
|
||
letterSpacing: '-6px', fontVariantNumeric: 'tabular-nums',
|
||
lineHeight: 1, textShadow: '0 0 60px rgba(99,102,241,0.2)',
|
||
}}>
|
||
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||
</div>
|
||
|
||
{/* Date */}
|
||
<div style={{
|
||
fontSize: 22, color: 'rgba(255,255,255,0.35)',
|
||
fontWeight: 500, textTransform: 'capitalize',
|
||
}}>
|
||
{time.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||
</div>
|
||
|
||
{/* Weather mini */}
|
||
{weather && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
marginTop: 16, color: 'rgba(255,255,255,0.3)',
|
||
}}>
|
||
<WeatherAnimation condition={weather.desc} size={36} />
|
||
<span style={{ fontSize: 24, fontWeight: 600 }}>{weather.temp}°</span>
|
||
<span style={{ fontSize: 16 }}>{weather.desc}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{
|
||
position: 'absolute', bottom: 40,
|
||
fontSize: 13, color: 'rgba(255,255,255,0.15)',
|
||
}}>
|
||
Коснитесь для разблокировки
|
||
</div>
|
||
</motion.div>
|
||
)
|
||
}
|
||
|
||
// ————— Lock Screen —————
|
||
function LockScreen({ onUnlock }: { onUnlock: () => void }) {
|
||
const [pin, setPin] = useState('')
|
||
const [error, setError] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [time, setTime] = useState(new Date())
|
||
|
||
useEffect(() => {
|
||
const t = setInterval(() => setTime(new Date()), 1000)
|
||
return () => clearInterval(t)
|
||
}, [])
|
||
|
||
const submit = async (fullPin: string) => {
|
||
setLoading(true); setError(false)
|
||
try {
|
||
const r = await fetch('/api/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pin: fullPin }),
|
||
})
|
||
if (r.ok) onUnlock()
|
||
else { setError(true); setPin(''); setTimeout(() => setError(false), 1500) }
|
||
} catch { setError(true); setPin('') }
|
||
finally { setLoading(false) }
|
||
}
|
||
|
||
const handleDigit = (d: string) => {
|
||
if (pin.length >= 6) return
|
||
const next = pin + d
|
||
setPin(next)
|
||
if (next.length === 4) submit(next)
|
||
}
|
||
|
||
const digits = ['1','2','3','4','5','6','7','8','9','','0','del']
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 200, background: '#0c0c18',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 40,
|
||
}}>
|
||
<div className="bg-ambient" />
|
||
<div style={{ textAlign: 'center', position: 'relative', zIndex: 1 }}>
|
||
<div style={{ fontSize: 64, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-3px', fontVariantNumeric: 'tabular-nums' }}>
|
||
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||
</div>
|
||
<div style={{ fontSize: 16, color: 'var(--text-secondary)', marginTop: 4, textTransform: 'capitalize', fontWeight: 500 }}>
|
||
{time.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20, position: 'relative', zIndex: 1 }}>
|
||
<div style={{
|
||
width: 56, height: 56, borderRadius: 18,
|
||
background: error ? 'rgba(239,68,68,0.15)' : 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
|
||
border: error ? '1px solid rgba(239,68,68,0.3)' : '1px solid rgba(129,140,248,0.25)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.3s ease',
|
||
}}>
|
||
<Lock size={24} color={error ? '#f87171' : '#a5b4fc'} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 14 }}>
|
||
{[0,1,2,3].map(i => (
|
||
<div key={i} style={{
|
||
width: 14, height: 14, borderRadius: '50%',
|
||
background: i < pin.length ? (error ? '#f87171' : '#a5b4fc') : 'rgba(255,255,255,0.1)',
|
||
border: `1px solid ${i < pin.length ? (error ? 'rgba(239,68,68,0.5)' : 'rgba(165,180,252,0.5)') : 'rgba(255,255,255,0.15)'}`,
|
||
transition: 'all 0.2s ease', transform: i < pin.length ? 'scale(1.15)' : 'scale(1)',
|
||
}} />
|
||
))}
|
||
</div>
|
||
{error && <div style={{ fontSize: 13, color: '#f87171', fontWeight: 500 }}>Неверный PIN</div>}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, position: 'relative', zIndex: 1 }}>
|
||
{digits.map((d, i) => {
|
||
if (d === '') return <div key={i} />
|
||
if (d === 'del') return (
|
||
<button key={i} onClick={() => setPin(p => p.slice(0, -1))} style={{
|
||
width: 72, height: 72, borderRadius: 20, background: 'rgba(255,255,255,0.03)',
|
||
border: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: 'var(--text-secondary)', transition: 'all 0.15s ease',
|
||
}}><Delete size={22} /></button>
|
||
)
|
||
return (
|
||
<button key={i} onClick={() => handleDigit(d)} style={{
|
||
width: 72, height: 72, borderRadius: 20, background: 'rgba(255,255,255,0.04)',
|
||
border: '1px solid rgba(255,255,255,0.07)', fontSize: 24, fontWeight: 600,
|
||
color: 'var(--text-primary)', transition: 'all 0.15s ease',
|
||
}}>{d}</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ————— Home Tab —————
|
||
// ————— Weather Day Detail Modal —————
|
||
function WeatherDayModal({ day, current, onClose }: {
|
||
day: { date: string; maxTemp: string; minTemp: string; desc: string; feelsLikeMax?: string; feelsLikeMin?: string; precipProb?: string; windSpeed?: string; humidity?: string }
|
||
current: WeatherData | null
|
||
onClose: () => void
|
||
}) {
|
||
const d = new Date(day.date)
|
||
const isToday = d.toDateString() === new Date().toDateString()
|
||
const dayLabel = isToday ? 'Сегодня' : d.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(12px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
|
||
<div style={{
|
||
background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
|
||
border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
|
||
width: 400, maxWidth: '90vw', overflow: 'hidden',
|
||
boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
|
||
}} onClick={e => e.stopPropagation()}>
|
||
|
||
{/* Hero */}
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, rgba(59,130,246,0.12), rgba(99,102,241,0.06))',
|
||
borderBottom: '1px solid rgba(59,130,246,0.1)',
|
||
padding: '28px 28px 24px', textAlign: 'center',
|
||
position: 'relative', overflow: 'hidden',
|
||
}}>
|
||
<div style={{ position: 'absolute', top: -10, right: 10, opacity: 0.1, pointerEvents: 'none' }}>
|
||
<WeatherAnimation condition={day.desc} size={100} />
|
||
</div>
|
||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||
<div style={{ fontSize: 14, color: 'var(--text-secondary)', fontWeight: 500, textTransform: 'capitalize', marginBottom: 12 }}>{dayLabel}</div>
|
||
<WeatherAnimation condition={day.desc} size={64} />
|
||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 8, marginTop: 12 }}>
|
||
<span style={{ fontSize: 42, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-2px' }}>{day.maxTemp}°</span>
|
||
<span style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-secondary)' }}>{day.minTemp}°</span>
|
||
</div>
|
||
<div style={{ fontSize: 15, color: 'var(--text-secondary)', marginTop: 6, fontWeight: 500 }}>{day.desc}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Details grid */}
|
||
<div style={{ padding: '22px 28px 28px' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
|
||
{[
|
||
{ icon: <Thermometer size={18} color="#fb923c" />, bg: 'rgba(251,146,60,0.1)', label: 'Ощущается', value: `${day.feelsLikeMax || '—'}° / ${day.feelsLikeMin || '—'}°` },
|
||
{ icon: <Droplets size={18} color="#3b82f6" />, bg: 'rgba(59,130,246,0.1)', label: 'Влажность', value: `${day.humidity || (isToday && current ? current.humidity : '—')}%` },
|
||
{ icon: <Wind size={18} color="#22d3ee" />, bg: 'rgba(34,211,238,0.1)', label: 'Ветер', value: `${day.windSpeed || (isToday && current ? current.windSpeed : '—')} м/с` },
|
||
{ icon: <span style={{ fontSize: 18 }}>🌧️</span>, bg: 'rgba(99,102,241,0.1)', label: 'Вероятность осадков', value: `${day.precipProb || '0'}%` },
|
||
].map(item => (
|
||
<div key={item.label} style={{
|
||
padding: '16px 14px', borderRadius: 16,
|
||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||
}}>
|
||
<div style={{
|
||
width: 38, height: 38, borderRadius: 12, background: item.bg,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}>{item.icon}</div>
|
||
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--text-primary)' }}>{item.value}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textAlign: 'center' }}>{item.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button onClick={onClose} style={{
|
||
width: '100%', padding: '13px', borderRadius: 14, marginTop: 16,
|
||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)',
|
||
color: 'var(--text-secondary)', fontSize: 14, fontWeight: 600,
|
||
}}>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) {
|
||
const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([])
|
||
const [tomorrowEvents, setTomorrowEvents] = useState<CalendarEvent[]>([])
|
||
const [calLoading, setCalLoading] = useState(true)
|
||
const [greeting, setGreeting] = useState(getGreeting())
|
||
const [pinnedNotes, setPinnedNotes] = useState<any[]>([])
|
||
const [selectedDay, setSelectedDay] = useState<any>(null)
|
||
|
||
useEffect(() => {
|
||
fetch('/api/calendar?range=today')
|
||
.then(r => r.json())
|
||
.then(d => setTodayEvents(d.events || []))
|
||
.catch(() => setTodayEvents([]))
|
||
.finally(() => setCalLoading(false))
|
||
|
||
// Tomorrow
|
||
const tomorrow = new Date()
|
||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||
const y = tomorrow.getFullYear()
|
||
const m = tomorrow.getMonth()
|
||
fetch(`/api/calendar?range=month&year=${y}&month=${m}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const tmr = (d.events || []).filter((e: any) => {
|
||
const ed = new Date(e.start)
|
||
return ed.getDate() === tomorrow.getDate() && ed.getMonth() === tomorrow.getMonth()
|
||
})
|
||
setTomorrowEvents(tmr)
|
||
})
|
||
.catch(() => {})
|
||
|
||
// Notes — pinned (pinDate today or future), fallback to latest
|
||
fetch('/api/notes')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const all = d.notes || []
|
||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||
const pinned = all.filter((n: any) => {
|
||
if (!n.pinDate) return false
|
||
const pd = new Date(n.pinDate); pd.setHours(0, 0, 0, 0)
|
||
return pd.getTime() >= today.getTime()
|
||
})
|
||
setPinnedNotes((pinned.length ? pinned : all).slice(0, 3))
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const t = setInterval(() => setGreeting(getGreeting()), 60000)
|
||
return () => clearInterval(t)
|
||
}, [])
|
||
|
||
// Weather hint
|
||
const getWeatherHint = (): string | null => {
|
||
if (!weather) return null
|
||
const desc = weather.desc.toLowerCase()
|
||
const temp = parseInt(weather.temp)
|
||
if (desc.includes('дождь') || desc.includes('ливен') || desc.includes('морос')) return '☂️ Не забудьте зонт'
|
||
if (desc.includes('снег')) return '🧤 На улице снег, одевайтесь теплее'
|
||
if (desc.includes('гроз')) return '⛈️ Ожидается гроза'
|
||
if (temp <= 0) return '🥶 На улице мороз'
|
||
if (temp <= 5) return '🧥 Оденьтесь потеплее'
|
||
if (temp >= 30) return '🥵 Очень жарко, пейте воду'
|
||
return null
|
||
}
|
||
|
||
const hint = getWeatherHint()
|
||
|
||
return (
|
||
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{/* Greeting + hint */}
|
||
<div>
|
||
<h1 style={{ fontSize: 26, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.5px', margin: 0 }}>
|
||
{greeting} 👋
|
||
</h1>
|
||
{hint && (
|
||
<div style={{
|
||
fontSize: 14, color: 'var(--text-primary)', marginTop: 8,
|
||
padding: '10px 16px', borderRadius: 12,
|
||
background: 'rgba(251,191,36,0.08)', border: '1px solid rgba(251,191,36,0.15)',
|
||
fontWeight: 500,
|
||
}}>
|
||
{hint}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Weather — full width compact */}
|
||
{weather && (
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.05))',
|
||
backdropFilter: 'blur(20px)', border: '1px solid rgba(129,140,248,0.1)',
|
||
borderRadius: 20, padding: '18px 22px',
|
||
display: 'flex', alignItems: 'center', gap: 20,
|
||
position: 'relative', overflow: 'hidden',
|
||
}}>
|
||
<div style={{ position: 'absolute', top: -15, right: 5, opacity: 0.1, pointerEvents: 'none' }}>
|
||
<WeatherAnimation condition={weather.desc} size={90} />
|
||
</div>
|
||
|
||
{/* Current */}
|
||
<div onClick={() => weather?.forecast?.[0] && setSelectedDay(weather.forecast[0])} style={{ display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0, position: 'relative', zIndex: 1, cursor: 'pointer' }}>
|
||
<WeatherAnimation condition={weather.desc} size={48} />
|
||
<div>
|
||
<div style={{ fontSize: 32, fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
|
||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3, fontWeight: 500 }}>{weather.desc}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: 1, height: 50, background: 'rgba(255,255,255,0.08)', flexShrink: 0 }} />
|
||
|
||
{/* 7 day forecast */}
|
||
{weather.forecast && (
|
||
<div style={{ display: 'flex', gap: 4, flex: 1, overflow: 'hidden' }}>
|
||
{weather.forecast.map((day, idx) => {
|
||
const d = new Date(day.date)
|
||
const isToday = idx === 0
|
||
return (
|
||
<div key={day.date} onClick={() => setSelectedDay(day)} style={{
|
||
flex: 1, minWidth: 0, textAlign: 'center', padding: '4px 2px',
|
||
borderRadius: 10, cursor: 'pointer',
|
||
background: isToday ? 'rgba(99,102,241,0.1)' : 'transparent',
|
||
transition: 'background 0.2s ease',
|
||
}}>
|
||
<div style={{ fontSize: 9, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', fontWeight: 600, marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{isToday ? 'Сей' : d.toLocaleDateString('ru-RU', { weekday: 'short' }).slice(0, 2)}
|
||
</div>
|
||
<div style={{ fontSize: 14, marginBottom: 2 }}>{getWeatherIcon(day.desc)}</div>
|
||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
|
||
<div style={{ fontSize: 9, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Transport: tram arrivals at Ул. Антонова-Овсеенко, both directions */}
|
||
<TransportWidget />
|
||
|
||
{/* Two columns: Events + Notes */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, flex: 1, minHeight: 0 }}>
|
||
|
||
{/* Left: Today + Tomorrow events */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{/* Today */}
|
||
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: '18px 20px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||
<Calendar size={14} color="var(--text-secondary)" />
|
||
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Сегодня</span>
|
||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{todayEvents.length}</span>
|
||
</div>
|
||
{calLoading ? (
|
||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Загрузка...</div>
|
||
) : todayEvents.length === 0 ? (
|
||
<div style={{ fontSize: 14, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>Свободный день</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{todayEvents.map(ev => (
|
||
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 12, background: `${ev.color}08`, border: `1px solid ${ev.color}12` }}>
|
||
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 30, flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ev.title}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
|
||
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)} · <span style={{ color: ev.color }}>{ev.ownerName}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tomorrow */}
|
||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderRadius: 20, padding: '18px 20px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||
<Calendar size={14} color="var(--text-secondary)" />
|
||
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Завтра</span>
|
||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{tomorrowEvents.length}</span>
|
||
</div>
|
||
{tomorrowEvents.length === 0 ? (
|
||
<div style={{ fontSize: 14, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>Нет событий</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{tomorrowEvents.map(ev => (
|
||
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 12, background: `${ev.color}06` }}>
|
||
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 24, flexShrink: 0, opacity: 0.6 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ev.title}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
|
||
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Pinned notes / shopping lists */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{pinnedNotes.length === 0 ? (
|
||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderRadius: 20, padding: '18px 20px', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||
<StickyNote size={24} style={{ margin: '0 auto 8px', opacity: 0.3 }} />
|
||
<div style={{ fontSize: 13 }}>Заметки появятся здесь</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
pinnedNotes.map(note => {
|
||
const doneCount = note.items?.filter((i: any) => i.done).length || 0
|
||
const totalCount = note.items?.length || 0
|
||
return (
|
||
<div key={note.id} style={{
|
||
background: `${note.color}08`, border: `1px solid ${note.color}15`,
|
||
borderRadius: 20, padding: '18px 20px',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||
{note.type === 'shopping' ? <ShoppingCart size={14} color={note.color} /> : <FileText size={14} color={note.color} />}
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{note.title}</span>
|
||
{note.type === 'shopping' && totalCount > 0 && (
|
||
<span style={{ fontSize: 11, color: note.color, marginLeft: 'auto' }}>{doneCount}/{totalCount}</span>
|
||
)}
|
||
</div>
|
||
{note.type === 'shopping' ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
{(note.items || []).filter((i: any) => !i.done).slice(0, 5).map((item: any) => (
|
||
<div key={item.id} style={{ fontSize: 13, color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<div style={{ width: 6, height: 6, borderRadius: 2, border: `1.5px solid ${note.color}50`, flexShrink: 0 }} />
|
||
{item.text}
|
||
</div>
|
||
))}
|
||
{(note.items || []).filter((i: any) => !i.done).length > 5 && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
|
||
+{(note.items || []).filter((i: any) => !i.done).length - 5} ещё
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical' as any }}>
|
||
{note.text || 'Пустая заметка'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weather day detail modal */}
|
||
{selectedDay && (
|
||
<WeatherDayModal day={selectedDay} current={weather} onClose={() => setSelectedDay(null)} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// ————— Settings Tab —————
|
||
function SettingsTab({ city, onCityChange, onLogout, theme, onThemeChange }: { city: string; onCityChange: (id: string) => void; onLogout: () => void; theme: string; onThemeChange: (t: string) => void }) {
|
||
const [showPinChange, setShowPinChange] = useState(false)
|
||
const [oldPin, setOldPin] = useState('')
|
||
const [newPin, setNewPin] = useState('')
|
||
const [pinMsg, setPinMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||
const [pinSaving, setPinSaving] = useState(false)
|
||
|
||
const changePIN = async () => {
|
||
if (!oldPin || !newPin) { setPinMsg({ text: 'Заполните оба поля', ok: false }); return }
|
||
if (newPin.length < 4) { setPinMsg({ text: 'Минимум 4 цифры', ok: false }); return }
|
||
setPinSaving(true); setPinMsg(null)
|
||
try {
|
||
const r = await fetch('/api/auth', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ oldPin, newPin }),
|
||
})
|
||
const d = await r.json()
|
||
if (d.error) throw new Error(d.error === 'wrong_pin' ? 'Неверный старый PIN' : d.error)
|
||
setPinMsg({ text: 'PIN изменён', ok: true })
|
||
setOldPin(''); setNewPin('')
|
||
setTimeout(() => { setShowPinChange(false); setPinMsg(null) }, 1500)
|
||
} catch (e: any) { setPinMsg({ text: e.message, ok: false }) }
|
||
finally { setPinSaving(false) }
|
||
}
|
||
|
||
const inputStyle: React.CSSProperties = {
|
||
padding: '14px 18px', borderRadius: 14, width: '100%',
|
||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
|
||
color: 'var(--text-primary)', fontSize: 15, outline: 'none', fontFamily: 'inherit',
|
||
textAlign: 'center', letterSpacing: '4px',
|
||
}
|
||
|
||
const currentCity = CITIES.find(c => c.id === city) || CITIES[0]
|
||
|
||
return (
|
||
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, touchAction: 'pan-y', padding: '24px', display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 560, margin: '0 auto', width: '100%' }}>
|
||
<h2 style={{ fontSize: 24, fontWeight: 800, color: 'var(--text-primary)', margin: '0 0 8px', letterSpacing: '-0.5px' }}>Настройки</h2>
|
||
|
||
{/* City selector */}
|
||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 22, padding: '22px 24px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||
<MapPin size={18} color="#818cf8" />
|
||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>Город</span>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
||
{CITIES.map(c => {
|
||
const isActive = city === c.id
|
||
return (
|
||
<button key={c.id} onClick={() => onCityChange(c.id)} style={{
|
||
padding: '12px 16px', borderRadius: 14, textAlign: 'left',
|
||
background: isActive ? 'rgba(99,102,241,0.12)' : 'rgba(255,255,255,0.02)',
|
||
border: `1px solid ${isActive ? 'rgba(129,140,248,0.25)' : 'rgba(255,255,255,0.05)'}`,
|
||
color: isActive ? '#a5b4fc' : 'var(--text-secondary)',
|
||
fontSize: 14, fontWeight: isActive ? 600 : 500,
|
||
transition: 'all 0.25s ease',
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
}}>
|
||
{isActive && <Check size={14} />}
|
||
{c.name}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Theme */}
|
||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 22, padding: '22px 24px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{ fontSize: 18 }}>{theme === 'dark' ? '🌙' : '☀️'}</span>
|
||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>Тема</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
{[
|
||
{ id: 'dark', label: 'Тёмная' },
|
||
{ id: 'light', label: 'Светлая' },
|
||
].map(t => (
|
||
<button key={t.id} onClick={() => onThemeChange(t.id)} style={{
|
||
padding: '8px 16px', borderRadius: 12,
|
||
background: theme === t.id ? 'rgba(99,102,241,0.12)' : 'rgba(255,255,255,0.02)',
|
||
border: theme === t.id ? '1px solid rgba(129,140,248,0.25)' : '1px solid var(--card-border)',
|
||
color: theme === t.id ? '#a5b4fc' : 'var(--text-secondary)',
|
||
fontSize: 13, fontWeight: theme === t.id ? 600 : 500,
|
||
transition: 'all 0.25s ease',
|
||
}}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* PIN change */}
|
||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 22, padding: '22px 24px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<KeyRound size={18} color="#818cf8" />
|
||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>PIN-код</span>
|
||
</div>
|
||
<button onClick={() => setShowPinChange(v => !v)} style={{
|
||
padding: '8px 16px', borderRadius: 12,
|
||
background: showPinChange ? 'rgba(255,255,255,0.04)' : 'rgba(99,102,241,0.1)',
|
||
border: `1px solid ${showPinChange ? 'rgba(255,255,255,0.06)' : 'rgba(129,140,248,0.2)'}`,
|
||
color: showPinChange ? 'var(--text-secondary)' : '#a5b4fc',
|
||
fontSize: 13, fontWeight: 600,
|
||
}}>
|
||
{showPinChange ? 'Отмена' : 'Изменить'}
|
||
</button>
|
||
</div>
|
||
|
||
{showPinChange && (
|
||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<input
|
||
type="password" inputMode="numeric" maxLength={8}
|
||
value={oldPin} onChange={e => setOldPin(e.target.value.replace(/\D/g, ''))}
|
||
placeholder="Старый PIN" style={inputStyle}
|
||
/>
|
||
<input
|
||
type="password" inputMode="numeric" maxLength={8}
|
||
value={newPin} onChange={e => setNewPin(e.target.value.replace(/\D/g, ''))}
|
||
placeholder="Новый PIN" style={inputStyle}
|
||
/>
|
||
{pinMsg && (
|
||
<div style={{
|
||
fontSize: 13, padding: '10px 14px', borderRadius: 12, fontWeight: 500,
|
||
background: pinMsg.ok ? 'rgba(52,211,153,0.08)' : 'rgba(239,68,68,0.08)',
|
||
color: pinMsg.ok ? '#34d399' : '#f87171',
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
}}>
|
||
{pinMsg.ok ? <Check size={14} /> : <XIcon size={14} />} {pinMsg.text}
|
||
</div>
|
||
)}
|
||
<button onClick={changePIN} disabled={pinSaving} style={{
|
||
padding: '14px', borderRadius: 14,
|
||
background: 'linear-gradient(135deg, rgba(99,102,241,0.3), rgba(139,92,246,0.2))',
|
||
border: '1px solid rgba(129,140,248,0.3)',
|
||
color: '#a5b4fc', fontSize: 14, fontWeight: 600,
|
||
}}>
|
||
{pinSaving ? 'Сохранение...' : 'Сохранить PIN'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Lock screen */}
|
||
<button onClick={() => { const e = new CustomEvent('activate-screensaver'); window.dispatchEvent(e) }} style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||
padding: '16px', borderRadius: 18,
|
||
background: 'rgba(99,102,241,0.08)',
|
||
border: '1px solid rgba(129,140,248,0.15)',
|
||
color: '#a5b4fc', fontSize: 15, fontWeight: 600,
|
||
transition: 'all 0.25s ease',
|
||
}}>
|
||
<Lock size={18} />
|
||
Режим часов
|
||
</button>
|
||
|
||
{/* Logout */}
|
||
<button onClick={onLogout} style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||
padding: '16px', borderRadius: 18,
|
||
background: 'rgba(239,68,68,0.06)',
|
||
border: '1px solid rgba(239,68,68,0.15)',
|
||
color: '#f87171', fontSize: 15, fontWeight: 600,
|
||
transition: 'all 0.25s ease', marginTop: 8,
|
||
}}>
|
||
<LogOut size={18} />
|
||
Выйти из аккаунта
|
||
</button>
|
||
|
||
{/* Info */}
|
||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-tertiary)', fontSize: 12 }}>
|
||
Smart Home Dashboard v1.0 · {currentCity.name}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ————— Tab animation variants —————
|
||
const tabVariants = {
|
||
enter: { opacity: 0, y: 12 },
|
||
center: { opacity: 1, y: 0 },
|
||
exit: { opacity: 0, y: -8 },
|
||
}
|
||
|
||
// ————— Main —————
|
||
function HomePageInner() {
|
||
const [unlocked, setUnlocked] = useState<boolean | null>(null)
|
||
const [tab, setTab] = useState<Tab>('home')
|
||
const [activeRoom, setActiveRoom] = useState('living')
|
||
const [weather, setWeather] = useState<WeatherData | null>(null)
|
||
const [sensors, setSensors] = useState<SensorData | null>(null)
|
||
const [haStates, setHaStates] = useState<HaStates>({})
|
||
const [haConnected, setHaConnected] = useState(false)
|
||
const [city, setCity] = useState(() => {
|
||
if (typeof window !== 'undefined') return localStorage.getItem('tablet-city') || 'spb'
|
||
return 'spb'
|
||
})
|
||
const [screensaverActive, setScreensaverActive] = useState(false)
|
||
const [theme, setTheme] = useState(() => {
|
||
if (typeof window !== 'undefined') return localStorage.getItem('tablet-theme') || 'dark'
|
||
return 'dark'
|
||
})
|
||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
// Theme
|
||
useEffect(() => {
|
||
document.documentElement.className = theme
|
||
localStorage.setItem('tablet-theme', theme)
|
||
}, [theme])
|
||
|
||
// Auth check
|
||
useEffect(() => {
|
||
fetch('/api/auth')
|
||
.then(r => r.json())
|
||
.then(d => setUnlocked(d.authenticated))
|
||
.catch(() => setUnlocked(false))
|
||
}, [])
|
||
|
||
// City change
|
||
const handleCityChange = (id: string) => {
|
||
setCity(id)
|
||
localStorage.setItem('tablet-city', id)
|
||
}
|
||
|
||
// Weather
|
||
useEffect(() => {
|
||
if (!unlocked) return
|
||
const c = CITIES.find(x => x.id === city) || CITIES[0]
|
||
const load = async () => {
|
||
try {
|
||
const r = await fetch(`/api/weather?lat=${c.lat}&lon=${c.lon}`)
|
||
const d = await r.json()
|
||
if (d.temp && d.temp !== '—') setWeather(d)
|
||
} catch {}
|
||
}
|
||
load()
|
||
const t = setInterval(load, 600_000)
|
||
return () => clearInterval(t)
|
||
}, [unlocked, city])
|
||
|
||
// HA
|
||
const loadHA = useCallback(async () => {
|
||
try {
|
||
const r = await fetch('/api/ha')
|
||
const d = await r.json()
|
||
if (d.states) { setHaStates(d.states); setHaConnected(true) }
|
||
if (d.sensors) setSensors(d.sensors)
|
||
} catch { setHaConnected(false) }
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!unlocked) return
|
||
loadHA()
|
||
const t = setInterval(loadHA, 30_000)
|
||
return () => clearInterval(t)
|
||
}, [loadHA, unlocked])
|
||
|
||
// Listen for manual screensaver activation
|
||
useEffect(() => {
|
||
const handler = () => setScreensaverActive(true)
|
||
window.addEventListener('activate-screensaver', handler)
|
||
return () => window.removeEventListener('activate-screensaver', handler)
|
||
}, [])
|
||
|
||
// Screensaver idle detection
|
||
const resetIdle = useCallback(() => {
|
||
if (screensaverActive) return // don't reset timer while screensaver is active
|
||
if (idleTimer.current) clearTimeout(idleTimer.current)
|
||
idleTimer.current = setTimeout(() => setScreensaverActive(true), 2 * 60 * 1000) // 2 min
|
||
}, [screensaverActive])
|
||
|
||
useEffect(() => {
|
||
if (!unlocked) return
|
||
const events = ['mousedown', 'mousemove', 'touchstart', 'keydown', 'scroll']
|
||
events.forEach(e => window.addEventListener(e, resetIdle, { passive: true }))
|
||
resetIdle()
|
||
return () => {
|
||
events.forEach(e => window.removeEventListener(e, resetIdle))
|
||
if (idleTimer.current) clearTimeout(idleTimer.current)
|
||
}
|
||
}, [unlocked, resetIdle])
|
||
|
||
const devicesInRoom = DEVICES_BY_ROOM[activeRoom] || []
|
||
const getDeviceState = (haKey?: string): boolean => {
|
||
if (!haKey || !haStates[haKey]) return false
|
||
return haStates[haKey].state === 'on'
|
||
}
|
||
const getDeviceExtra = (id: string): string | undefined => {
|
||
if (id === 'air_purifier' && sensors) return `PM2.5: ${sensors.pm25}`
|
||
return undefined
|
||
}
|
||
|
||
const handleLogout = async () => {
|
||
await fetch('/api/auth', { method: 'DELETE' })
|
||
window.location.reload()
|
||
}
|
||
|
||
if (unlocked === null) {
|
||
return <div style={{ display: 'flex', height: '100dvh', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}><div className="bg-ambient" /></div>
|
||
}
|
||
if (!unlocked) {
|
||
return <LockScreen onUnlock={() => setUnlocked(true)} />
|
||
}
|
||
|
||
return (
|
||
<div className={getWeatherBgClass(weather?.desc || null)} style={{ display: 'flex', height: '100dvh', width: '100%', background: 'var(--bg)', overflow: 'hidden', position: 'relative' }}>
|
||
<div className="bg-ambient" />
|
||
|
||
<AnimatePresence>
|
||
{screensaverActive && (
|
||
<Screensaver weather={weather} onDismiss={() => setScreensaverActive(false)} />
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<Sidebar active={tab} onChange={setTab} />
|
||
|
||
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, position: 'relative', zIndex: 1 }}>
|
||
<TopBar sensors={sensors} haConnected={haConnected} />
|
||
|
||
<AnimatePresence mode="wait">
|
||
{tab === 'home' && (
|
||
<motion.div key="home" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<HomeTab weather={weather} sensors={sensors} />
|
||
</motion.div>
|
||
)}
|
||
|
||
{tab === 'devices' && (
|
||
<motion.div key="devices" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} />
|
||
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '16px 24px 28px' }}>
|
||
{devicesInRoom.length === 0 ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: 220, color: 'var(--text-secondary)', gap: 12 }}>
|
||
<div style={{ 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 style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 14 }}>
|
||
{devicesInRoom.map(device => (
|
||
<DeviceCard key={device.id} id={device.id} name={device.name} icon={device.icon} entityId={device.entityId} domain={device.domain} initialState={getDeviceState(device.haKey)} isMock={device.isMock} extraInfo={getDeviceExtra(device.id)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{tab === 'calendar' && (
|
||
<motion.div key="calendar" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||
<CalendarTab />
|
||
</motion.div>
|
||
)}
|
||
|
||
{tab === 'notes' && (
|
||
<motion.div key="notes" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<NotesTab />
|
||
</motion.div>
|
||
)}
|
||
|
||
{tab === 'settings' && (
|
||
<motion.div key="settings" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<SettingsTab city={city} onCityChange={handleCityChange} onLogout={handleLogout} theme={theme} onThemeChange={setTheme} />
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function HomePage() {
|
||
return <HomePageInner />
|
||
}
|