Files
smart-home-tablet/app/page.tsx
Cosmo 38a64ff9c8
Some checks failed
Deploy to VM / deploy (push) Failing after 1s
feat: google calendar integration, calendar tab, redesign home/devices tabs
2026-04-22 12:44:15 +00:00

395 lines
13 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 } from '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'
type Tab = 'home' | 'devices' | 'calendar' | 'settings'
interface WeatherData {
temp: string
desc: string
humidity: string
windSpeed: string
feelsLike: string
forecast?: { date: string; maxTemp: string; minTemp: string; desc: 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: [],
}
function getWeatherEmoji(desc: string): string {
const d = desc?.toLowerCase() || ''
if (d.includes('ясно') || d.includes('солнеч')) return '☀️'
if (d.includes('облач')) return '⛅'
if (d.includes('пасмурн')) return '☁️'
if (d.includes('дождь') || d.includes('морос')) return '🌧️'
if (d.includes('снег')) return '❄️'
if (d.includes('гроз')) return '⛈️'
return '🌤️'
}
function formatEventTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
// ————— Home Tab —————
function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) {
const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([])
const [calLoading, setCalLoading] = useState(true)
useEffect(() => {
fetch('/api/calendar?range=today')
.then(r => r.json())
.then(d => setTodayEvents(d.events || []))
.catch(() => setTodayEvents([]))
.finally(() => setCalLoading(false))
}, [])
return (
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Today Widget */}
<div style={{
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 20,
padding: '18px 20px',
}}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
📅 Сегодня
</div>
{calLoading ? (
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Загрузка...</div>
) : todayEvents.length === 0 ? (
<div style={{ fontSize: 15, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>
Свободный день 🎉
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{todayEvents.map(ev => (
<div key={ev.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 32, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.title}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2, display: 'flex', gap: 6 }}>
<span>{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)}${formatEventTime(ev.end)}`}</span>
<span style={{ color: ev.color }}>{ev.ownerName}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Weather Widget */}
{weather && (
<div style={{
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 20,
padding: '18px 20px',
}}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
🌤 Погода
</div>
{/* Current */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
<span style={{ fontSize: 44 }}>{getWeatherEmoji(weather.desc)}</span>
<div>
<div style={{ fontSize: 32, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{weather.temp}°C</div>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4 }}>{weather.desc}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>
💧 {weather.humidity}% · 💨 {weather.windSpeed} км/ч · Ощущается {weather.feelsLike}°
</div>
</div>
</div>
{/* Forecast */}
{weather.forecast && weather.forecast.length > 0 && (
<div style={{ display: 'flex', gap: 8 }}>
{weather.forecast.slice(0, 3).map(day => {
const d = new Date(day.date)
const label = d.toLocaleDateString('ru-RU', { weekday: 'short' })
return (
<div key={day.date} style={{
flex: 1,
background: 'rgba(255,255,255,0.04)',
borderRadius: 12,
padding: '10px 8px',
textAlign: 'center',
border: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 20, marginBottom: 4 }}>{getWeatherEmoji(day.desc)}</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* Sensors Widget */}
{sensors && (
<div style={{
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 20,
padding: '18px 20px',
}}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
📊 Датчики квартиры
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
{[
{ label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' },
{ label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' },
{ label: 'PM2.5', value: `${sensors.pm25} μg`, icon: '💨' },
].map(s => (
<div key={s.label} style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14,
padding: '12px 10px',
textAlign: 'center',
}}>
<div style={{ fontSize: 24, marginBottom: 6 }}>{s.icon}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
export default function HomePage() {
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>({})
useEffect(() => {
const load = async () => {
try {
const r = await fetch('/api/weather')
const d = await r.json()
if (d.temp && d.temp !== '—') setWeather(d)
} catch {}
}
load()
const t = setInterval(load, 600_000)
return () => clearInterval(t)
}, [])
const loadHA = useCallback(async () => {
try {
const r = await fetch('/api/ha')
const d = await r.json()
if (d.states) setHaStates(d.states)
if (d.sensors) setSensors(d.sensors)
} catch {}
}, [])
useEffect(() => {
loadHA()
const t = setInterval(loadHA, 30_000)
return () => clearInterval(t)
}, [loadHA])
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
}
return (
<div style={{
display: 'flex',
height: '100dvh',
width: '100%',
background: 'var(--bg)',
overflow: 'hidden',
}}>
<Sidebar active={tab} onChange={setTab} />
<main style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minWidth: 0,
}}>
<TopBar weather={weather} sensors={sensors} />
{tab === 'home' && <HomeTab weather={weather} sensors={sensors} />}
{tab === 'devices' && (
<>
<RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} />
<div style={{
flex: 1,
overflowY: 'auto',
WebkitOverflowScrolling: 'touch' as any,
padding: '16px 20px 24px',
}}>
{devicesInRoom.length === 0 ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: 200,
color: 'var(--text-secondary)',
gap: 8,
}}>
<span style={{ fontSize: 40 }}>🏠</span>
<span style={{ fontSize: 15 }}>Устройства не добавлены</span>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
{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>
</>
)}
{tab === 'calendar' && <CalendarTab />}
{tab === 'settings' && (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-secondary)',
}}>
<span style={{ fontSize: 48 }}></span>
<span style={{ fontSize: 16 }}>Настройки</span>
<span style={{ fontSize: 13 }}>Скоро</span>
</div>
)}
</main>
</div>
)
}