feat: add PIN lock screen auth + calendar owner filter toggles
All checks were successful
Deploy / deploy (push) Successful in 2m49s
All checks were successful
Deploy / deploy (push) Successful in 2m49s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
37
app/api/auth/route.ts
Normal 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
|
||||||
|
}
|
||||||
366
app/page.tsx
366
app/page.tsx
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
40
middleware.ts
Normal 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).*)'],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user