feat: google calendar integration, calendar tab, redesign home/devices tabs
Some checks failed
Deploy to VM / deploy (push) Failing after 1s

This commit is contained in:
Cosmo
2026-04-22 12:44:15 +00:00
parent b7facc25b8
commit 38a64ff9c8
6 changed files with 2837 additions and 142 deletions

View File

@@ -5,8 +5,9 @@ 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' | 'rooms' | 'sensors' | 'settings'
type Tab = 'home' | 'devices' | 'calendar' | 'settings'
interface WeatherData {
temp: string
@@ -27,6 +28,17 @@ 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 },
@@ -90,6 +102,162 @@ const DEVICES_BY_ROOM: Record<string, {
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')
@@ -97,7 +265,6 @@ export default function HomePage() {
const [sensors, setSensors] = useState<SensorData | null>(null)
const [haStates, setHaStates] = useState<HaStates>({})
// Load weather
useEffect(() => {
const load = async () => {
try {
@@ -111,7 +278,6 @@ export default function HomePage() {
return () => clearInterval(t)
}, [])
// Load HA states + sensors
const loadHA = useCallback(async () => {
try {
const r = await fetch('/api/ha')
@@ -142,63 +308,50 @@ export default function HomePage() {
}
return (
<div
style={{
display: 'flex',
height: '100dvh',
width: '100%',
background: 'var(--bg)',
overflow: 'hidden',
}}
>
<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,
}}
>
<main style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minWidth: 0,
}}>
<TopBar weather={weather} sensors={sensors} />
{tab === 'home' && (
{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',
}}
>
<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,
}}
>
<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,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
{devicesInRoom.map(device => (
<DeviceCard
key={device.id}
@@ -218,89 +371,18 @@ export default function HomePage() {
</>
)}
{tab === 'rooms' && (
<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>
)}
{tab === 'sensors' && sensors && (
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Датчики</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
{[
{ label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' },
{ label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' },
{ label: 'PM2.5', value: `${sensors.pm25} μg/m³`, icon: '💨' },
].map(s => (
<div
key={s.label}
style={{
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 18,
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<span style={{ fontSize: 28 }}>{s.icon}</span>
<div style={{ fontSize: 22, fontWeight: 700 }}>{s.value}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{s.label}</div>
</div>
))}
</div>
</div>
)}
{tab === 'sensors' && !sensors && (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-secondary)',
}}
>
Загрузка датчиков...
</div>
)}
{tab === 'calendar' && <CalendarTab />}
{tab === 'settings' && (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-secondary)',
}}
>
<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>