feat: add PIN lock screen auth + calendar owner filter toggles
All checks were successful
Deploy / deploy (push) Successful in 2m49s

This commit is contained in:
Cosmo
2026-04-22 18:50:56 +00:00
parent eb644ff341
commit 1a529fc23e
5 changed files with 440 additions and 332 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.tablet.env

37
app/api/auth/route.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import * as crypto from 'crypto'
const SECRET = process.env.APP_SECRET || 'smart-home-default-secret-change-me'
function makeToken(pin: string): string {
return crypto.createHmac('sha256', SECRET).update(pin).digest('hex')
}
export async function POST(req: Request) {
const { pin } = await req.json()
const correctPin = process.env.APP_PIN || '1234'
if (pin !== correctPin) {
return NextResponse.json({ error: 'wrong_pin' }, { status: 401 })
}
const token = makeToken(correctPin)
const res = NextResponse.json({ success: true })
res.cookies.set('auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year — tablet stays logged in
})
return res
}
export async function DELETE() {
const res = NextResponse.json({ success: true })
res.cookies.delete('auth_token')
return res
}

View File

@@ -1,7 +1,8 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, Suspense } from 'react'
import { Thermometer, Droplets, Wind, Calendar, Sun, CloudRain, Snowflake as SnowIcon, Cloud, CloudSun, Zap, Settings as SettingsIcon } from 'lucide-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 Sidebar from '@/components/Sidebar'
import TopBar from '@/components/TopBar' import TopBar from '@/components/TopBar'
import RoomTabs from '@/components/RoomTabs' import RoomTabs from '@/components/RoomTabs'
@@ -92,6 +93,161 @@ function getPm25Level(pm25: number): { label: string; color: string; bg: string
return { label: 'Плохо', color: '#f87171', bg: 'rgba(248,113,113,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 ————— // ————— Home Tab —————
function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) { function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) {
const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([]) const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([])
@@ -109,33 +265,19 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
return ( return (
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Top row: Weather + Sensors side by side */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Weather Card */}
{weather && ( {weather && (
<div style={{ <div style={{
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.06))', background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.06))',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(20px)',
border: '1px solid rgba(129,140,248,0.12)', border: '1px solid rgba(129,140,248,0.12)',
borderRadius: 22, borderRadius: 22, padding: '22px 24px',
padding: '22px 24px', position: 'relative', overflow: 'hidden',
position: 'relative',
overflow: 'hidden',
}}>
{/* Background decoration */}
<div style={{
position: 'absolute', top: -20, right: -10,
fontSize: 80, opacity: 0.12, pointerEvents: 'none',
}}> }}>
<div style={{ position: 'absolute', top: -20, right: -10, fontSize: 80, opacity: 0.12, pointerEvents: 'none' }}>
{getWeatherIcon(weather.desc)} {getWeatherIcon(weather.desc)}
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>Погода</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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18, position: 'relative', zIndex: 1 }}>
<span style={{ fontSize: 44 }}>{getWeatherIcon(weather.desc)}</span> <span style={{ fontSize: 44 }}>{getWeatherIcon(weather.desc)}</span>
<div> <div>
@@ -143,22 +285,13 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div> <div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
</div> </div>
</div> </div>
{/* Forecast mini */}
{weather.forecast && weather.forecast.length > 0 && ( {weather.forecast && weather.forecast.length > 0 && (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
{weather.forecast.slice(0, 3).map(day => { {weather.forecast.slice(0, 3).map(day => {
const d = new Date(day.date) const d = new Date(day.date)
const label = d.toLocaleDateString('ru-RU', { weekday: 'short' }) const label = d.toLocaleDateString('ru-RU', { weekday: 'short' })
return ( return (
<div key={day.date} style={{ <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)' }}>
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: 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: 20, marginBottom: 4 }}>{getWeatherIcon(day.desc)}</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div> <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
@@ -171,26 +304,12 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
</div> </div>
)} )}
{/* Sensors Card */}
{sensors && ( {sensors && (
<div style={{ <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' }}>
background: 'rgba(255,255,255,0.03)', <div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 16 }}>Климат в квартире</div>
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', flexDirection: 'column', gap: 14 }}>
{/* Temperature */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ <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' }}>
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" /> <Thermometer size={22} color="#fb923c" />
</div> </div>
<div> <div>
@@ -198,14 +317,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Температура</div> <div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Температура</div>
</div> </div>
</div> </div>
{/* Humidity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ <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' }}>
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" /> <Droplets size={22} color="#3b82f6" />
</div> </div>
<div> <div>
@@ -213,14 +326,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Влажность</div> <div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Влажность</div>
</div> </div>
</div> </div>
{/* PM2.5 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ <div style={{ width: 48, height: 48, borderRadius: 16, background: pm25Info?.bg || 'rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
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'} /> <Wind size={22} color={pm25Info?.color || '#999'} />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -236,48 +343,22 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
)} )}
</div> </div>
{/* Today Events */} <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={{
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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Calendar size={15} color="var(--text-secondary)" /> <Calendar size={15} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600 }}> <span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600 }}>Сегодня</span>
Сегодня
</span>
</div> </div>
{calLoading ? ( {calLoading ? (
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Загрузка...</div> <div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Загрузка...</div>
) : todayEvents.length === 0 ? ( ) : todayEvents.length === 0 ? (
<div style={{ fontSize: 15, color: 'var(--text-secondary)', textAlign: 'center', padding: '12px 0' }}> <div style={{ fontSize: 15, color: 'var(--text-secondary)', textAlign: 'center', padding: '12px 0' }}>Нет событий на сегодня</div>
Нет событий на сегодня
</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{todayEvents.map(ev => ( {todayEvents.map(ev => (
<div key={ev.id} style={{ <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` }}>
display: 'flex', <div style={{ width: 4, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 36, flexShrink: 0 }} />
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={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ev.title}</div>
{ev.title}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 3, display: 'flex', gap: 8 }}> <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>{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)}${formatEventTime(ev.end)}`}</span>
<span style={{ color: ev.color, fontWeight: 500 }}>{ev.ownerName}</span> <span style={{ color: ev.color, fontWeight: 500 }}>{ev.ownerName}</span>
@@ -292,7 +373,11 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
) )
} }
export default function HomePage() { function HomePageInner() {
const searchParams = useSearchParams()
const isLocked = searchParams.get('locked') === '1'
const [unlocked, setUnlocked] = useState(!isLocked)
const [tab, setTab] = useState<Tab>('home') const [tab, setTab] = useState<Tab>('home')
const [activeRoom, setActiveRoom] = useState('living') const [activeRoom, setActiveRoom] = useState('living')
const [weather, setWeather] = useState<WeatherData | null>(null) const [weather, setWeather] = useState<WeatherData | null>(null)
@@ -300,6 +385,7 @@ export default function HomePage() {
const [haStates, setHaStates] = useState<HaStates>({}) const [haStates, setHaStates] = useState<HaStates>({})
useEffect(() => { useEffect(() => {
if (!unlocked) return
const load = async () => { const load = async () => {
try { try {
const r = await fetch('/api/weather') const r = await fetch('/api/weather')
@@ -310,7 +396,7 @@ export default function HomePage() {
load() load()
const t = setInterval(load, 600_000) const t = setInterval(load, 600_000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [unlocked])
const loadHA = useCallback(async () => { const loadHA = useCallback(async () => {
try { try {
@@ -322,10 +408,11 @@ export default function HomePage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!unlocked) return
loadHA() loadHA()
const t = setInterval(loadHA, 30_000) const t = setInterval(loadHA, 30_000)
return () => clearInterval(t) return () => clearInterval(t)
}, [loadHA]) }, [loadHA, unlocked])
const devicesInRoom = DEVICES_BY_ROOM[activeRoom] || [] const devicesInRoom = DEVICES_BY_ROOM[activeRoom] || []
@@ -339,29 +426,24 @@ export default function HomePage() {
return undefined 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 ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex', height: '100dvh', width: '100%',
height: '100dvh', background: 'var(--bg)', overflow: 'hidden', position: 'relative',
width: '100%',
background: 'var(--bg)',
overflow: 'hidden',
position: 'relative',
}}> }}>
{/* Ambient background */}
<div className="bg-ambient" /> <div className="bg-ambient" />
<Sidebar active={tab} onChange={setTab} /> <Sidebar active={tab} onChange={setTab} />
<main style={{ <main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, position: 'relative', zIndex: 1 }}>
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minWidth: 0,
position: 'relative',
zIndex: 1,
}}>
<TopBar weather={weather} sensors={sensors} /> <TopBar weather={weather} sensors={sensors} />
{tab === 'home' && <HomeTab weather={weather} sensors={sensors} />} {tab === 'home' && <HomeTab weather={weather} sensors={sensors} />}
@@ -369,28 +451,10 @@ export default function HomePage() {
{tab === 'devices' && ( {tab === 'devices' && (
<> <>
<RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} /> <RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} />
<div style={{ <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '16px 24px 28px' }}>
flex: 1,
overflowY: 'auto',
WebkitOverflowScrolling: 'touch' as any,
padding: '16px 24px 28px',
}}>
{devicesInRoom.length === 0 ? ( {devicesInRoom.length === 0 ? (
<div style={{ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: 220, color: 'var(--text-secondary)', gap: 12 }}>
display: 'flex', <div style={{ width: 64, height: 64, borderRadius: 20, background: 'rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28 }}>🏠</div>
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> <span style={{ fontSize: 15, fontWeight: 500 }}>Устройства не добавлены</span>
</div> </div>
) : ( ) : (
@@ -417,15 +481,7 @@ export default function HomePage() {
{tab === 'calendar' && <CalendarTab />} {tab === 'calendar' && <CalendarTab />}
{tab === 'settings' && ( {tab === 'settings' && (
<div style={{ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20, color: 'var(--text-secondary)' }}>
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
color: 'var(--text-secondary)',
}}>
<div style={{ <div style={{
width: 72, height: 72, borderRadius: 22, width: 72, height: 72, borderRadius: 22,
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.08))', background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.08))',
@@ -435,10 +491,32 @@ export default function HomePage() {
<SettingsIcon size={32} color="#818cf8" /> <SettingsIcon size={32} color="#818cf8" />
</div> </div>
<span style={{ fontSize: 18, fontWeight: 600 }}>Настройки</span> <span style={{ fontSize: 18, fontWeight: 600 }}>Настройки</span>
<span style={{ fontSize: 13 }}>Скоро</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> </div>
)} )}
</main> </main>
</div> </div>
) )
} }
export default function HomePage() {
return (
<Suspense>
<HomePageInner />
</Suspense>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2 } from 'lucide-react' import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2, Eye, EyeOff } from 'lucide-react'
interface CalendarEvent { interface CalendarEvent {
id: string id: string
@@ -28,31 +28,21 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
const [error, setError] = useState('') const [error, setError] = useState('')
const inputStyle = { const inputStyle = {
padding: '12px 16px', padding: '12px 16px', borderRadius: 14,
borderRadius: 14, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.05)', color: 'var(--text-primary)', fontSize: 14, outline: 'none', fontFamily: 'inherit',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-primary)',
fontSize: 14,
outline: 'none',
fontFamily: 'inherit',
transition: 'border-color 0.2s ease',
} }
const save = async () => { const save = async () => {
if (!title.trim()) { setError('Введите название'); return } if (!title.trim()) { setError('Введите название'); return }
setSaving(true) setSaving(true); setError('')
setError('')
try { try {
const body = { title: title.trim(), date, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, allDay } const body = { title: title.trim(), date, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, allDay }
const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json() const d = await r.json()
if (d.error) throw new Error(d.error) if (d.error) throw new Error(d.error)
onSaved(d.event) onSaved(d.event)
} catch (e: any) { } catch (e: any) { setError(e.message || 'Ошибка сохранения'); setSaving(false) }
setError(e.message || 'Ошибка сохранения')
setSaving(false)
}
} }
return ( return (
@@ -62,7 +52,6 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span> <span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span>
<button onClick={onClose} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button> <button onClick={onClose} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Название события" autoFocus style={inputStyle} /> <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Название события" autoFocus style={inputStyle} />
<input type="date" value={date} onChange={e => setDate(e.target.value)} style={inputStyle} /> <input type="date" value={date} onChange={e => setDate(e.target.value)} style={inputStyle} />
@@ -73,25 +62,15 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
</div> </div>
)} )}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13, cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} /> <input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} /> Весь день
Весь день
</label> </label>
{error && <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div>} {error && <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div>}
<button <button onClick={save} disabled={saving} style={{
onClick={save} padding: '13px', borderRadius: 14,
disabled={saving}
style={{
padding: '13px',
borderRadius: 14,
background: saving ? 'rgba(99,102,241,0.2)' : 'linear-gradient(135deg, rgba(99,102,241,0.4), rgba(139,92,246,0.3))', background: saving ? 'rgba(99,102,241,0.2)' : 'linear-gradient(135deg, rgba(99,102,241,0.4), rgba(139,92,246,0.3))',
border: '1px solid rgba(129,140,248,0.3)', border: '1px solid rgba(129,140,248,0.3)', color: '#a5b4fc', fontSize: 14, fontWeight: 600,
color: '#a5b4fc',
fontSize: 14,
fontWeight: 600,
cursor: saving ? 'default' : 'pointer', cursor: saving ? 'default' : 'pointer',
transition: 'all 0.25s ease', }}>
}}
>
{saving ? 'Сохранение...' : 'Создать событие'} {saving ? 'Сохранение...' : 'Создать событие'}
</button> </button>
</div> </div>
@@ -110,6 +89,7 @@ export default function CalendarTab() {
const [addDate, setAddDate] = useState<string>('') const [addDate, setAddDate] = useState<string>('')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [hiddenOwners, setHiddenOwners] = useState<Set<string>>(new Set())
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
@@ -128,14 +108,36 @@ export default function CalendarTab() {
setEvents(prev => prev.filter(e => e.id !== event.id)) setEvents(prev => prev.filter(e => e.id !== event.id))
setSelectedEvent(null) setSelectedEvent(null)
setConfirmDelete(false) setConfirmDelete(false)
} catch (e: any) { } catch (e: any) { alert(e.message || 'Ошибка удаления') }
alert(e.message || 'Ошибка удаления') finally { setDeleting(false) }
} finally {
setDeleting(false)
}
} }
const upcoming = events // Discover unique calendar owners from loaded events
const calendarOwners = useMemo(() => {
const map = new Map<string, { owner: string; ownerName: string; color: string; count: number }>()
events.forEach(e => {
const existing = map.get(e.owner)
if (existing) { existing.count++ }
else { map.set(e.owner, { owner: e.owner, ownerName: e.ownerName, color: e.color, count: 1 }) }
})
return Array.from(map.values())
}, [events])
const toggleOwner = (owner: string) => {
setHiddenOwners(prev => {
const next = new Set(prev)
if (next.has(owner)) next.delete(owner)
else next.add(owner)
return next
})
}
const filteredEvents = useMemo(() => {
if (hiddenOwners.size === 0) return events
return events.filter(e => !hiddenOwners.has(e.owner))
}, [events, hiddenOwners])
const upcoming = filteredEvents
.filter(e => new Date(e.start) >= new Date()) .filter(e => new Date(e.start) >= new Date())
.slice(0, 6) .slice(0, 6)
@@ -150,7 +152,7 @@ export default function CalendarTab() {
} }
const getEventsForDay = (day: number) => { const getEventsForDay = (day: number) => {
return events.filter(e => { return filteredEvents.filter(e => {
const d = new Date(e.start) const d = new Date(e.start)
return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day
}) })
@@ -158,7 +160,6 @@ export default function CalendarTab() {
const prevMonth = () => { if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1) } const prevMonth = () => { if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1) }
const nextMonth = () => { if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1) } const nextMonth = () => { if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1) }
const today = new Date() const today = new Date()
return ( return (
@@ -166,47 +167,65 @@ export default function CalendarTab() {
{/* Main calendar grid */} {/* Main calendar grid */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={prevMonth} style={{ <button onClick={prevMonth} style={{ width: 36, height: 36, borderRadius: 12, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
width: 36, height: 36, borderRadius: 12,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
color: 'var(--text-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s ease',
}}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</button> </button>
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', minWidth: 180, textAlign: 'center' }}> <span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', minWidth: 180, textAlign: 'center' }}>
{MONTHS[month]} {year} {MONTHS[month]} {year}
</span> </span>
<button onClick={nextMonth} style={{ <button onClick={nextMonth} style={{ width: 36, height: 36, borderRadius: 12, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
width: 36, height: 36, borderRadius: 12,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
color: 'var(--text-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s ease',
}}>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* Calendar owner filters */}
{calendarOwners.map(cal => {
const isHidden = hiddenOwners.has(cal.owner)
return (
<button
key={cal.owner}
onClick={() => toggleOwner(cal.owner)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 14px', borderRadius: 12,
background: isHidden ? 'rgba(255,255,255,0.02)' : `${cal.color}15`,
border: `1px solid ${isHidden ? 'rgba(255,255,255,0.06)' : cal.color + '30'}`,
color: isHidden ? 'var(--text-tertiary)' : cal.color,
fontSize: 12, fontWeight: 600,
transition: 'all 0.25s ease',
opacity: isHidden ? 0.5 : 1,
}}
>
{isHidden ? <EyeOff size={13} /> : <Eye size={13} />}
{cal.ownerName}
<span style={{
fontSize: 10, padding: '1px 6px', borderRadius: 6,
background: isHidden ? 'rgba(255,255,255,0.05)' : `${cal.color}20`,
}}>
{cal.count}
</span>
</button>
)
})}
<button <button
onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }} onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 18px', borderRadius: 14, padding: '8px 18px', borderRadius: 14,
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))', background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
border: '1px solid rgba(129,140,248,0.25)', border: '1px solid rgba(129,140,248,0.25)',
color: '#a5b4fc', fontSize: 13, fontWeight: 600, color: '#a5b4fc', fontSize: 13, fontWeight: 600,
transition: 'all 0.25s ease',
}} }}
> >
<Plus size={15} /> <Plus size={15} />
Событие Событие
</button> </button>
</div> </div>
</div>
{/* Weekday headers */} {/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 6 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 6 }}>
@@ -238,43 +257,26 @@ export default function CalendarTab() {
} }
}} }}
style={{ style={{
borderRadius: 12, borderRadius: 12, padding: '5px 4px',
padding: '5px 4px', background: isToday ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))' : 'rgba(255,255,255,0.015)',
background: isToday border: isToday ? '1px solid rgba(129,140,248,0.3)' : '1px solid transparent',
? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))' cursor: 'pointer', minHeight: 56,
: 'rgba(255,255,255,0.015)', display: 'flex', flexDirection: 'column', gap: 2,
border: isToday
? '1px solid rgba(129,140,248,0.3)'
: '1px solid transparent',
cursor: 'pointer',
minHeight: 56,
display: 'flex',
flexDirection: 'column',
gap: 2,
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
}} }}
> >
<span style={{ <span style={{
fontSize: 12, fontSize: 12, fontWeight: isToday ? 700 : 500,
fontWeight: isToday ? 700 : 500,
color: isToday ? '#a5b4fc' : isWeekend ? 'rgba(248,113,113,0.6)' : 'var(--text-secondary)', color: isToday ? '#a5b4fc' : isWeekend ? 'rgba(248,113,113,0.6)' : 'var(--text-secondary)',
textAlign: 'right', paddingRight: 4, textAlign: 'right', paddingRight: 4,
}}>{day}</span> }}>{day}</span>
{dayEvents.slice(0, 2).map(e => ( {dayEvents.slice(0, 2).map(e => (
<div <div key={e.id} onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }} style={{
key={e.id}
onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }}
style={{
fontSize: 10, fontWeight: 600, fontSize: 10, fontWeight: 600,
background: e.color + '1a', background: e.color + '1a', border: `1px solid ${e.color}30`,
border: `1px solid ${e.color}30`, color: e.color, borderRadius: 6, padding: '2px 5px',
color: e.color, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
borderRadius: 6, }}>
padding: '2px 5px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
cursor: 'pointer',
}}
>
{e.title} {e.title}
</div> </div>
))} ))}
@@ -287,15 +289,11 @@ export default function CalendarTab() {
</div> </div>
</div> </div>
{/* Right panel: upcoming events */} {/* Right panel */}
<div style={{ <div style={{
width: 220, flexShrink: 0, width: 220, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 10,
display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', background: 'rgba(255,255,255,0.02)', borderRadius: 22,
overflowY: 'auto', padding: '20px 16px', border: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(255,255,255,0.02)',
borderRadius: 22,
padding: '20px 16px',
border: '1px solid rgba(255,255,255,0.04)',
}}> }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Ближайшие</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Ближайшие</div>
{upcoming.length === 0 && !loading && ( {upcoming.length === 0 && !loading && (
@@ -304,24 +302,13 @@ export default function CalendarTab() {
{upcoming.map(e => { {upcoming.map(e => {
const d = new Date(e.start) const d = new Date(e.start)
return ( return (
<div <div key={e.id} onClick={() => setSelectedEvent(e)} style={{
key={e.id} borderRadius: 16, padding: '14px',
onClick={() => setSelectedEvent(e)} background: `${e.color}0c`, border: `1px solid ${e.color}1a`,
style={{ cursor: 'pointer', transition: 'all 0.25s ease',
borderRadius: 16,
padding: '14px 14px',
background: `${e.color}0c`,
border: `1px solid ${e.color}1a`,
cursor: 'pointer',
transition: 'all 0.25s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{
width: 32, height: 32, borderRadius: 10,
background: `${e.color}1a`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 32, height: 32, borderRadius: 10, background: `${e.color}1a`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span> <span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
</div> </div>
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div> <div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
@@ -340,11 +327,9 @@ export default function CalendarTab() {
{selectedEvent && ( {selectedEvent && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }}>
<div style={{ <div style={{
background: 'rgba(18,18,35,0.95)', background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(40px)',
backdropFilter: 'blur(40px)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 24, padding: 28,
border: '1px solid rgba(255,255,255,0.08)', maxWidth: 380, width: '100%', boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
borderRadius: 24, padding: 28, maxWidth: 380, width: '100%',
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div style={{ display: 'flex', gap: 14 }}> <div style={{ display: 'flex', gap: 14 }}>
@@ -356,78 +341,45 @@ export default function CalendarTab() {
</div> </div>
<button onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button> <button onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderRadius: 12, background: 'rgba(255,255,255,0.03)', color: 'var(--text-secondary)', fontSize: 13 }}>
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.03)',
color: 'var(--text-secondary)', fontSize: 13,
}}>
<Clock size={15} /> <Clock size={15} />
{selectedEvent.allDay {selectedEvent.allDay ? 'Весь день' : `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
? 'Весь день'
: `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
}
</div> </div>
{selectedEvent.location && ( {selectedEvent.location && (
<div style={{ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderRadius: 12, background: 'rgba(255,255,255,0.03)', color: 'var(--text-secondary)', fontSize: 13 }}>
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.03)',
color: 'var(--text-secondary)', fontSize: 13,
}}>
<MapPin size={15} /> {selectedEvent.location} <MapPin size={15} /> {selectedEvent.location}
</div> </div>
)} )}
{selectedEvent.description && ( {selectedEvent.description && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.6 }}>{selectedEvent.description}</div> <div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.6 }}>{selectedEvent.description}</div>
)} )}
{/* Delete button */}
<div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 14 }}> <div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 14 }}>
{!confirmDelete ? ( {!confirmDelete ? (
<button <button onClick={() => setConfirmDelete(true)} style={{
onClick={() => setConfirmDelete(true)}
style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px', borderRadius: 12, padding: '10px 14px', borderRadius: 12,
background: 'rgba(239,68,68,0.08)', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.2)',
color: '#f87171', fontSize: 13, fontWeight: 600, color: '#f87171', fontSize: 13, fontWeight: 600,
width: '100%', justifyContent: 'center', width: '100%', justifyContent: 'center',
transition: 'all 0.25s ease', }}>
}} <Trash2 size={14} /> Удалить событие
>
<Trash2 size={14} />
Удалить событие
</button> </button>
) : ( ) : (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button onClick={() => deleteEvent(selectedEvent)} disabled={deleting} style={{
onClick={() => deleteEvent(selectedEvent)}
disabled={deleting}
style={{
flex: 1, padding: '10px 14px', borderRadius: 12, flex: 1, padding: '10px 14px', borderRadius: 12,
background: deleting ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.2)', background: deleting ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.35)', border: '1px solid rgba(239,68,68,0.35)',
color: '#f87171', fontSize: 13, fontWeight: 600, color: '#f87171', fontSize: 13, fontWeight: 600,
cursor: deleting ? 'default' : 'pointer', }}>
transition: 'all 0.25s ease',
}}
>
{deleting ? 'Удаление...' : 'Да, удалить'} {deleting ? 'Удаление...' : 'Да, удалить'}
</button> </button>
<button <button onClick={() => setConfirmDelete(false)} style={{
onClick={() => setConfirmDelete(false)}
style={{
flex: 1, padding: '10px 14px', borderRadius: 12, flex: 1, padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.04)', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600,
transition: 'all 0.25s ease', }}>
}}
>
Отмена Отмена
</button> </button>
</div> </div>

40
middleware.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import * as crypto from 'crypto'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Allow auth API and static assets
if (
pathname.startsWith('/api/auth') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon') ||
pathname === '/manifest.json'
) {
return NextResponse.next()
}
const token = request.cookies.get('auth_token')?.value
const pin = process.env.APP_PIN || '1234'
const secret = process.env.APP_SECRET || 'smart-home-default-secret-change-me'
const expectedToken = crypto.createHmac('sha256', secret).update(pin).digest('hex')
if (token !== expectedToken) {
// For API routes, return 401
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
// For page requests, rewrite to show login (handled client-side)
const url = request.nextUrl.clone()
url.searchParams.set('locked', '1')
return NextResponse.rewrite(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|manifest.json).*)'],
}