feat: google calendar integration, calendar tab, redesign home/devices tabs
Some checks failed
Deploy to VM / deploy (push) Failing after 1s
Some checks failed
Deploy to VM / deploy (push) Failing after 1s
This commit is contained in:
342
app/page.tsx
342
app/page.tsx
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user