Files
smart-home-tablet/app/page.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +00:00

1166 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'
import VoiceOverlay from '@/components/VoiceOverlay'
import TimerWidget from '@/components/TimerWidget'
import TimerHomeWidget from '@/components/TimerHomeWidget'
import FocusCard from '@/components/FocusCard'
import CountdownCard from '@/components/CountdownCard'
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
const TAB_ORDER: 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 —————
type ForecastDay = { date: string; maxTemp: string; minTemp: string; desc: string; feelsLikeMax?: string; feelsLikeMin?: string; precipProb?: string; windSpeed?: string; humidity?: string }
function WeatherDayModal({ day, days, current, onClose, onChange }: {
day: ForecastDay
days: ForecastDay[]
current: WeatherData | null
onClose: () => void
onChange: (d: ForecastDay) => 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' })
const idx = days.findIndex(x => x.date === day.date)
const canPrev = idx > 0
const canNext = idx >= 0 && idx < days.length - 1
const go = (delta: number) => {
const next = days[idx + delta]
if (next) onChange(next)
}
return (
<div data-swipe-ignore 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}>
<motion.div
key={day.date}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.15}
onDragEnd={(_, info) => {
if (info.offset.x < -60 && canNext) go(1)
else if (info.offset.x > 60 && canPrev) go(-1)
}}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 320, damping: 28 }}
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border-subtle)', borderRadius: 28,
width: 420, maxWidth: '90vw', overflow: 'hidden',
boxShadow: 'var(--shadow-xl)',
touchAction: 'pan-y',
}}
onClick={e => e.stopPropagation()}
>
{/* Hero */}
<div style={{
background: 'linear-gradient(135deg, var(--accent-glow), transparent)',
borderBottom: '1px solid var(--hairline)',
padding: '24px 28px 22px', textAlign: 'center',
position: 'relative', overflow: 'hidden',
}}>
{/* Prev/Next chevrons */}
{canPrev && (
<button
onClick={() => go(-1)}
aria-label="Предыдущий день"
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
width: 48, height: 48, borderRadius: 14,
background: 'var(--surface-2)', border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)', zIndex: 2, fontSize: 22,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
></button>
)}
{canNext && (
<button
onClick={() => go(1)}
aria-label="Следующий день"
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
width: 48, height: 48, borderRadius: 14,
background: 'var(--surface-2)', border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)', zIndex: 2, fontSize: 22,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
></button>
)}
<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: '20px 24px 20px' }}>
<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: '14px 12px', borderRadius: 14,
background: 'var(--surface-2)', border: '1px solid var(--border-subtle)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
}}>
<div style={{
width: 36, height: 36, borderRadius: 11, background: item.bg,
display: 'flex', alignItems: 'center', 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)', textAlign: 'center' }}>{item.label}</div>
</div>
))}
</div>
{/* Dot indicator */}
{days.length > 1 && (
<div style={{ display: 'flex', gap: 5, justifyContent: 'center', marginTop: 14 }}>
{days.map((di, i) => (
<button
key={di.date}
onClick={() => onChange(di)}
style={{
width: i === idx ? 18 : 6, height: 6, borderRadius: 3,
background: i === idx ? 'var(--accent)' : 'var(--surface-3)',
transition: 'all 0.25s ease',
}}
/>
))}
</div>
)}
<button onClick={onClose} style={{
width: '100%', padding: '16px', borderRadius: 14, marginTop: 12,
minHeight: 48,
background: 'var(--surface-2)', border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)', fontSize: 14, fontWeight: 600,
}}>
Закрыть
</button>
</div>
</motion.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 [pinnedNotes, setPinnedNotes] = useState<any[]>([])
const [selectedDay, setSelectedDay] = useState<any>(null)
const [countdowns, setCountdowns] = useState<{ label: string; date: string }[]>([])
const [tramNext, setTramNext] = useState<{ route: string; minutes: number; direction: string } | null>(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(() => {})
// Countdowns
fetch('/api/countdowns')
.then(r => r.json())
.then(d => setCountdowns((d.countdowns || []).map((c: any) => ({ label: c.label, date: c.date }))))
.catch(() => {})
}, [])
// Nearest upcoming tram — refresh every 30s so FocusCard stays current
useEffect(() => {
let cancelled = false
const STOP_IDS = [
{ id: '16226', direction: 'в центр' },
{ id: '16354', direction: 'от центра' },
]
const load = async () => {
try {
const results = await Promise.all(
STOP_IDS.map(s =>
fetch(`/api/transport?stopId=${s.id}`)
.then(r => r.json())
.then(j => (j.arrivals || []).map((a: any) => ({ ...a, direction: s.direction })))
.catch(() => [])
)
)
const all = results.flat().filter(a => typeof a.minutes === 'number' && a.minutes >= 0)
all.sort((a: any, b: any) => a.minutes - b.minutes)
if (!cancelled) {
setTramNext(all[0] ? {
route: all[0].route,
minutes: all[0].minutes,
direction: all[0].direction,
} : null)
}
} catch {}
}
load()
const t = setInterval(load, 30_000)
return () => { cancelled = true; clearInterval(t) }
}, [])
// Next event (today or tomorrow) — сейчас or nearest upcoming within next 24h
const nextEvent = (() => {
const now = Date.now()
const pool = [...todayEvents, ...tomorrowEvents]
.filter(e => !e.allDay)
.map(e => ({ e, t: new Date(e.start).getTime() }))
.filter(({ t }) => t >= now - 5 * 60_000)
.sort((a, b) => a.t - b.t)
return pool[0]?.e || null
})()
return (
<div style={{
flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any,
padding: '18px 22px 24px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
{/* ───── Bento row: Focus hero + Tram ───── */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.1fr)', gap: 14, alignItems: 'stretch' }}>
{/* Focus — контекст-hero */}
<FocusCard
weather={weather ? { temp: weather.temp, desc: weather.desc, feelsLike: weather.feelsLike } : null}
tramNext={tramNext}
nextEvent={nextEvent ? {
id: nextEvent.id, title: nextEvent.title, start: nextEvent.start,
allDay: nextEvent.allDay, ownerName: nextEvent.ownerName, color: nextEvent.color,
} : null}
countdowns={countdowns}
/>
{/* Tram */}
<TransportWidget />
</div>
{/* ───── Forecast band (no cards, hairline-separated) ───── */}
{weather?.forecast && (
<div className="card" style={{
padding: '12px 6px',
display: 'flex', alignItems: 'stretch',
}}>
{weather.forecast.map((day, idx) => {
const d = new Date(day.date)
const isToday = idx === 0
return (
<div key={day.date} style={{
flex: 1, display: 'flex', alignItems: 'stretch',
borderRight: idx < weather.forecast!.length - 1 ? '1px solid var(--hairline)' : 'none',
}}>
<button
onClick={() => setSelectedDay(day)}
style={{
flex: 1, padding: '12px 8px', borderRadius: 14,
minHeight: 92,
background: isToday ? 'var(--surface-2)' : 'transparent',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
transition: 'background 0.2s ease',
}}
>
<div style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase',
color: isToday ? 'var(--accent)' : 'var(--text-tertiary)',
}}>
{isToday ? 'Сей' : d.toLocaleDateString('ru-RU', { weekday: 'short' }).replace('.', '').slice(0, 2)}
</div>
<div style={{ fontSize: 22 }}>{getWeatherIcon(day.desc)}</div>
<div className="num" style={{
fontSize: 15, fontWeight: 800, color: 'var(--text-primary)',
letterSpacing: '-0.5px',
}}>
{day.maxTemp}°
</div>
<div className="num" style={{
fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 500,
}}>
{day.minTemp}°
</div>
</button>
</div>
)
})}
</div>
)}
{/* ───── Events + Timers row ───── */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 14 }}>
{/* Events — today + tomorrow in one card */}
<div className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Today */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Calendar size={13} color="var(--text-secondary)" />
<span style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Сегодня</span>
<span style={{
fontSize: 10, color: 'var(--text-tertiary)',
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
}}>
{todayEvents.length}
</span>
</div>
{calLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Загрузка...</div>
) : todayEvents.length === 0 ? (
<div style={{ fontSize: 14, color: 'var(--text-tertiary)', textAlign: 'center', padding: '6px 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: '8px 10px', borderRadius: 12,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
}}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 28, 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, fontWeight: 600 }}>{ev.ownerName}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Divider */}
{(tomorrowEvents.length > 0 || todayEvents.length > 0) && (
<div style={{ height: 1, background: 'var(--hairline)' }} />
)}
{/* Tomorrow */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Calendar size={13} color="var(--text-tertiary)" />
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Завтра</span>
<span style={{
fontSize: 10, color: 'var(--text-tertiary)',
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
}}>
{tomorrowEvents.length}
</span>
</div>
{tomorrowEvents.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-tertiary)', textAlign: 'center', padding: '4px 0' }}></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{tomorrowEvents.map(ev => (
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px' }}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 22, flexShrink: 0, opacity: 0.7 }} />
<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-tertiary)', marginTop: 1 }}>
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Timers (replaces Notes on Home) */}
<TimerHomeWidget />
</div>
{/* ───── Row 4: Countdown ───── */}
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(260px, 1fr) minmax(0, 2fr)',
gap: 14,
}}>
<CountdownCard />
<div /> {/* место под будущий виджет */}
</div>
{/* Weather day detail modal */}
{selectedDay && (
<WeatherDayModal
day={selectedDay}
days={weather?.forecast || []}
current={weather}
onClose={() => setSelectedDay(null)}
onChange={setSelectedDay}
/>
)}
</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 },
center: { opacity: 1 },
exit: { opacity: 0 },
}
// ————— 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 [nightShift, setNightShift] = useState(false)
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Theme
useEffect(() => {
document.documentElement.className = theme
localStorage.setItem('tablet-theme', theme)
}, [theme])
// Night-shift tint (22:0006:00)
useEffect(() => {
const check = () => {
const h = new Date().getHours()
setNightShift(h >= 22 || h < 6)
}
check()
const t = setInterval(check, 60_000)
return () => clearInterval(t)
}, [])
// 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])
// Swipe between tabs — edge-initiated horizontal drag
const swipeStart = useRef<{ x: number; y: number; t: number; id: number } | null>(null)
const handlePointerDown = (e: React.PointerEvent) => {
// Only primary pointer / touch
if (e.pointerType === 'mouse' && e.button !== 0) return
swipeStart.current = { x: e.clientX, y: e.clientY, t: Date.now(), id: e.pointerId }
}
const handlePointerUp = (e: React.PointerEvent) => {
const s = swipeStart.current
swipeStart.current = null
if (!s || s.id !== e.pointerId) return
const dx = e.clientX - s.x
const dy = e.clientY - s.y
const dt = Date.now() - s.t
// Conditions: fast enough, mostly horizontal, big enough
const isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.6
const isLong = Math.abs(dx) > 90
const isFast = dt < 600 || Math.abs(dx) > 160
if (!(isHorizontal && isLong && isFast)) return
// Don't steal swipe from elements that draggable-own (notes swipe-to-delete, weather modal, timer cards).
const target = e.target as HTMLElement | null
if (target?.closest('[data-swipe-ignore]')) return
const idx = TAB_ORDER.indexOf(tab)
if (idx < 0) return
const nextIdx = dx < 0 ? idx + 1 : idx - 1
if (nextIdx < 0 || nextIdx >= TAB_ORDER.length) return
setTab(TAB_ORDER[nextIdx])
}
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>
<VoiceOverlay />
<TimerWidget />
<Sidebar active={tab} onChange={setTab} />
{/* Night-shift warm tint overlay */}
<div
aria-hidden
style={{
position: 'fixed', inset: 0, zIndex: 150,
background: 'rgba(255, 120, 40, 0.12)',
mixBlendMode: 'multiply',
pointerEvents: 'none',
opacity: nightShift ? 1 : 0,
transition: 'opacity 0.8s ease',
}}
/>
<main
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={() => { swipeStart.current = null }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, position: 'relative', zIndex: 1 }}
>
<TopBar sensors={sensors} haConnected={haConnected} />
<AnimatePresence mode="sync" initial={false}>
{tab === 'home' && (
<motion.div key="home" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.15 }} 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.15 }} 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.15 }} 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.15 }} 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.15 }} 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 />
}