Files
smart-home-tablet/app/page.tsx
Cosmo 1a529fc23e
All checks were successful
Deploy / deploy (push) Successful in 2m49s
feat: add PIN lock screen auth + calendar owner filter toggles
2026-04-22 18:50:56 +00:00

523 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { Thermometer, Droplets, Wind, Calendar, Lock, Settings as SettingsIcon, LogOut, Delete } 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'
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 getWeatherIcon(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' })
}
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)' }
}
// ————— 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 handleDelete = () => {
setPin(p => p.slice(0, -1))
}
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,
}}>
{/* Ambient orbs */}
<div className="bg-ambient" />
{/* Time */}
<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>
{/* Lock icon + PIN dots */}
<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>
{/* PIN dots */}
<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>
{/* Numpad */}
<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={handleDelete} 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 —————
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))
}, [])
const pm25Info = sensors ? getPm25Level(sensors.pm25) : null
return (
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{weather && (
<div style={{
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.06))',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(129,140,248,0.12)',
borderRadius: 22, padding: '22px 24px',
position: 'relative', overflow: 'hidden',
}}>
<div style={{ position: 'absolute', top: -20, right: -10, fontSize: 80, opacity: 0.12, pointerEvents: 'none' }}>
{getWeatherIcon(weather.desc)}
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>Погода</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18, position: 'relative', zIndex: 1 }}>
<span style={{ fontSize: 44 }}>{getWeatherIcon(weather.desc)}</span>
<div>
<div style={{ fontSize: 36, fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
</div>
</div>
{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: 14, padding: '10px 8px', textAlign: 'center', border: '1px solid rgba(255,255,255,0.04)' }}>
<div style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4, fontWeight: 500 }}>{label}</div>
<div style={{ fontSize: 20, marginBottom: 4 }}>{getWeatherIcon(day.desc)}</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
</div>
)
})}
</div>
)}
</div>
)}
{sensors && (
<div style={{ background: 'rgba(255,255,255,0.03)', backdropFilter: 'blur(20px)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 22, padding: '22px 24px' }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>Климат в квартире</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ width: 48, height: 48, borderRadius: 16, background: 'linear-gradient(135deg, rgba(251,146,60,0.15), rgba(245,158,11,0.08))', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Thermometer size={22} color="#fb923c" />
</div>
<div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{sensors.temperature}°C</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Температура</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ width: 48, height: 48, borderRadius: 16, background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(99,102,241,0.08))', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Droplets size={22} color="#3b82f6" />
</div>
<div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{sensors.humidity}%</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Влажность</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ width: 48, height: 48, borderRadius: 16, background: pm25Info?.bg || 'rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Wind size={22} color={pm25Info?.color || '#999'} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{sensors.pm25}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>µg/m³</span>
</div>
<div style={{ fontSize: 12, color: pm25Info?.color, marginTop: 2, fontWeight: 500 }}>PM2.5 · {pm25Info?.label}</div>
</div>
</div>
</div>
</div>
)}
</div>
<div style={{ background: 'rgba(255,255,255,0.03)', backdropFilter: 'blur(20px)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Calendar size={15} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600 }}>Сегодня</span>
</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: '12px 0' }}>Нет событий на сегодня</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{todayEvents.map(ev => (
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '12px 16px', borderRadius: 14, background: `${ev.color}0a`, border: `1px solid ${ev.color}18` }}>
<div style={{ width: 4, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 36, 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: 3, display: 'flex', gap: 8 }}>
<span>{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)}${formatEventTime(ev.end)}`}</span>
<span style={{ color: ev.color, fontWeight: 500 }}>{ev.ownerName}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function HomePageInner() {
const searchParams = useSearchParams()
const isLocked = searchParams.get('locked') === '1'
const [unlocked, setUnlocked] = useState(!isLocked)
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(() => {
if (!unlocked) return
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)
}, [unlocked])
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(() => {
if (!unlocked) return
loadHA()
const t = setInterval(loadHA, 30_000)
return () => clearInterval(t)
}, [loadHA, unlocked])
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) {
return <LockScreen onUnlock={() => { setUnlocked(true); window.history.replaceState({}, '', '/') }} />
}
return (
<div style={{
display: 'flex', height: '100dvh', width: '100%',
background: 'var(--bg)', overflow: 'hidden', position: 'relative',
}}>
<div className="bg-ambient" />
<Sidebar active={tab} onChange={setTab} />
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, position: 'relative', zIndex: 1 }}>
<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 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>
</>
)}
{tab === 'calendar' && <CalendarTab />}
{tab === 'settings' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20, color: 'var(--text-secondary)' }}>
<div style={{
width: 72, height: 72, borderRadius: 22,
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.08))',
border: '1px solid rgba(129,140,248,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<SettingsIcon size={32} color="#818cf8" />
</div>
<span style={{ fontSize: 18, fontWeight: 600 }}>Настройки</span>
<button
onClick={handleLogout}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '12px 24px', borderRadius: 14,
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.2)',
color: '#f87171', fontSize: 14, fontWeight: 600,
transition: 'all 0.25s ease',
}}
>
<LogOut size={16} />
Выйти
</button>
</div>
)}
</main>
</div>
)
}
export default function HomePage() {
return (
<Suspense>
<HomePageInner />
</Suspense>
)
}