'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; _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 = { 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 ( {/* Subtle ambient */}
{/* Time */}
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
{/* Date */}
{time.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })}
{/* Weather mini */} {weather && (
{weather.temp}° {weather.desc}
)}
Коснитесь для разблокировки
) } // ————— 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 (
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
{time.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })}
{[0,1,2,3].map(i => (
))}
{error &&
Неверный PIN
}
{digits.map((d, i) => { if (d === '') return
if (d === 'del') return ( ) return ( ) })}
) } // ————— 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 (
{ 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 */}
{/* Prev/Next chevrons */} {canPrev && ( )} {canNext && ( )}
{dayLabel}
{day.maxTemp}° {day.minTemp}°
{day.desc}
{/* Details grid */}
{[ { icon: , bg: 'rgba(251,146,60,0.1)', label: 'Ощущается', value: `${day.feelsLikeMax || '—'}° / ${day.feelsLikeMin || '—'}°` }, { icon: , bg: 'rgba(59,130,246,0.1)', label: 'Влажность', value: `${day.humidity || (isToday && current ? current.humidity : '—')}%` }, { icon: , bg: 'rgba(34,211,238,0.1)', label: 'Ветер', value: `${day.windSpeed || (isToday && current ? current.windSpeed : '—')} м/с` }, { icon: 🌧️, bg: 'rgba(99,102,241,0.1)', label: 'Осадки', value: `${day.precipProb || '0'}%` }, ].map(item => (
{item.icon}
{item.value}
{item.label}
))}
{/* Dot indicator */} {days.length > 1 && (
{days.map((di, i) => (
)}
) } function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) { const [todayEvents, setTodayEvents] = useState([]) const [tomorrowEvents, setTomorrowEvents] = useState([]) const [calLoading, setCalLoading] = useState(true) const [pinnedNotes, setPinnedNotes] = useState([]) const [selectedDay, setSelectedDay] = useState(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 (
{/* ───── Bento row: Focus hero + Tram ───── */}
{/* Focus — контекст-hero */} {/* Tram */}
{/* ───── Forecast band (no cards, hairline-separated) ───── */} {weather?.forecast && (
{weather.forecast.map((day, idx) => { const d = new Date(day.date) const isToday = idx === 0 return (
) })}
)} {/* ───── Events + Timers row ───── */}
{/* Events — today + tomorrow in one card */}
{/* Today */}
Сегодня {todayEvents.length}
{calLoading ? (
Загрузка...
) : todayEvents.length === 0 ? (
Свободный день ✨
) : (
{todayEvents.map(ev => (
{ev.title}
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)} · {ev.ownerName}
))}
)}
{/* Divider */} {(tomorrowEvents.length > 0 || todayEvents.length > 0) && (
)} {/* Tomorrow */}
Завтра {tomorrowEvents.length}
{tomorrowEvents.length === 0 ? (
) : (
{tomorrowEvents.map(ev => (
{ev.title}
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)}
))}
)}
{/* Timers (replaces Notes on Home) */}
{/* ───── Row 4: Countdown ───── */}
{/* место под будущий виджет */}
{/* Weather day detail modal */} {selectedDay && ( setSelectedDay(null)} onChange={setSelectedDay} /> )}
) } // ————— 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 (

Настройки

{/* City selector */}
Город
{CITIES.map(c => { const isActive = city === c.id return ( ) })}
{/* Theme */}
{theme === 'dark' ? '🌙' : '☀️'} Тема
{[ { id: 'dark', label: 'Тёмная' }, { id: 'light', label: 'Светлая' }, ].map(t => ( ))}
{/* PIN change */}
PIN-код
{showPinChange && (
setOldPin(e.target.value.replace(/\D/g, ''))} placeholder="Старый PIN" style={inputStyle} /> setNewPin(e.target.value.replace(/\D/g, ''))} placeholder="Новый PIN" style={inputStyle} /> {pinMsg && (
{pinMsg.ok ? : } {pinMsg.text}
)}
)}
{/* Lock screen */} {/* Logout */} {/* Info */}
Smart Home Dashboard v1.0 · {currentCity.name}
) } // ————— Tab animation variants ————— const tabVariants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 }, } // ————— Main ————— function HomePageInner() { const [unlocked, setUnlocked] = useState(null) const [tab, setTab] = useState('home') const [activeRoom, setActiveRoom] = useState('living') const [weather, setWeather] = useState(null) const [sensors, setSensors] = useState(null) const [haStates, setHaStates] = useState({}) 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 | null>(null) // Theme useEffect(() => { document.documentElement.className = theme localStorage.setItem('tablet-theme', theme) }, [theme]) // Night-shift tint (22:00–06: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
} if (!unlocked) { return setUnlocked(true)} /> } return (
{screensaverActive && ( setScreensaverActive(false)} /> )} {/* Night-shift warm tint overlay */}
{ swipeStart.current = null }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, position: 'relative', zIndex: 1 }} > {tab === 'home' && ( )} {tab === 'devices' && (
{devicesInRoom.length === 0 ? (
🏠
Устройства не добавлены
) : (
{devicesInRoom.map(device => ( ))}
)}
)} {tab === 'calendar' && ( )} {tab === 'notes' && ( )} {tab === 'settings' && ( )}
) } export default function HomePage() { return }