feat: google calendar integration, calendar tab, redesign home/devices tabs
Some checks failed
Deploy to VM / deploy (push) Failing after 1s
Some checks failed
Deploy to VM / deploy (push) Failing after 1s
This commit is contained in:
75
app/api/calendar/route.ts
Normal file
75
app/api/calendar/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { google } from 'googleapis'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const range = searchParams.get('range') || 'today'
|
||||||
|
|
||||||
|
const clientId = process.env.GOOGLE_CLIENT_ID
|
||||||
|
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
|
||||||
|
const refreshToken = process.env.GOOGLE_REFRESH_TOKEN
|
||||||
|
const svetaCalendarId = process.env.SVETA_CALENDAR_ID
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !refreshToken) {
|
||||||
|
return NextResponse.json({ events: [], error: 'not_configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = new google.auth.OAuth2(clientId, clientSecret)
|
||||||
|
auth.setCredentials({ refresh_token: refreshToken })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
let timeMin = todayStart.toISOString()
|
||||||
|
let timeMax: string
|
||||||
|
|
||||||
|
if (range === 'today') {
|
||||||
|
timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString()
|
||||||
|
} else if (range === 'week') {
|
||||||
|
timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString()
|
||||||
|
} else {
|
||||||
|
timeMax = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59).toISOString()
|
||||||
|
timeMin = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarClient = google.calendar({ version: 'v3', auth })
|
||||||
|
|
||||||
|
const calendars = [
|
||||||
|
{ id: 'daniilklimov25@gmail.com', owner: 'daniil', color: '#6366f1', name: 'Даниил' },
|
||||||
|
...(svetaCalendarId ? [{ id: svetaCalendarId, owner: 'sveta', color: '#ec4899', name: 'Света' }] : [])
|
||||||
|
]
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
calendars.map(cal =>
|
||||||
|
calendarClient.events.list({
|
||||||
|
calendarId: cal.id,
|
||||||
|
timeMin,
|
||||||
|
timeMax,
|
||||||
|
singleEvents: true,
|
||||||
|
orderBy: 'startTime',
|
||||||
|
maxResults: 100,
|
||||||
|
}).then(r => ({ ...cal, events: r.data.items || [] }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const allEvents = results
|
||||||
|
.filter(r => r.status === 'fulfilled')
|
||||||
|
.flatMap(r => {
|
||||||
|
const val = (r as PromiseFulfilledResult<any>).value
|
||||||
|
return val.events.map((e: any) => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.summary || '(без названия)',
|
||||||
|
start: e.start?.dateTime || e.start?.date,
|
||||||
|
end: e.end?.dateTime || e.end?.date,
|
||||||
|
allDay: !e.start?.dateTime,
|
||||||
|
description: e.description || null,
|
||||||
|
location: e.location || null,
|
||||||
|
owner: val.owner,
|
||||||
|
ownerName: val.name,
|
||||||
|
color: val.color,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||||
|
|
||||||
|
return NextResponse.json({ events: allEvents, fetchedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
286
app/page.tsx
286
app/page.tsx
@@ -5,8 +5,9 @@ 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'
|
||||||
import DeviceCard from '@/components/DeviceCard'
|
import DeviceCard from '@/components/DeviceCard'
|
||||||
|
import CalendarTab from '@/components/CalendarTab'
|
||||||
|
|
||||||
type Tab = 'home' | 'rooms' | 'sensors' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'settings'
|
||||||
|
|
||||||
interface WeatherData {
|
interface WeatherData {
|
||||||
temp: string
|
temp: string
|
||||||
@@ -27,6 +28,17 @@ interface HaStates {
|
|||||||
[key: string]: { state: string; attributes?: Record<string, any>; _mock?: boolean }
|
[key: string]: { state: string; attributes?: Record<string, any>; _mock?: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CalendarEvent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
allDay: boolean
|
||||||
|
owner: string
|
||||||
|
ownerName: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
const ROOMS = [
|
const ROOMS = [
|
||||||
{ id: 'living', name: 'Гостиная', emoji: '🛋️', deviceCount: 3 },
|
{ id: 'living', name: 'Гостиная', emoji: '🛋️', deviceCount: 3 },
|
||||||
{ id: 'bedroom', name: 'Спальня', emoji: '🛏️', deviceCount: 2 },
|
{ id: 'bedroom', name: 'Спальня', emoji: '🛏️', deviceCount: 2 },
|
||||||
@@ -90,6 +102,162 @@ const DEVICES_BY_ROOM: Record<string, {
|
|||||||
bathroom: [],
|
bathroom: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWeatherEmoji(desc: string): string {
|
||||||
|
const d = desc?.toLowerCase() || ''
|
||||||
|
if (d.includes('ясно') || d.includes('солнеч')) return '☀️'
|
||||||
|
if (d.includes('облач')) return '⛅'
|
||||||
|
if (d.includes('пасмурн')) return '☁️'
|
||||||
|
if (d.includes('дождь') || d.includes('морос')) return '🌧️'
|
||||||
|
if (d.includes('снег')) return '❄️'
|
||||||
|
if (d.includes('гроз')) return '⛈️'
|
||||||
|
return '🌤️'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Home Tab —————
|
||||||
|
function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) {
|
||||||
|
const [todayEvents, setTodayEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [calLoading, setCalLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/calendar?range=today')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setTodayEvents(d.events || []))
|
||||||
|
.catch(() => setTodayEvents([]))
|
||||||
|
.finally(() => setCalLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Today Widget */}
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--card-bg)',
|
||||||
|
border: '1px solid var(--card-border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '18px 20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
|
||||||
|
📅 Сегодня
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calLoading ? (
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Загрузка...</div>
|
||||||
|
) : todayEvents.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 15, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>
|
||||||
|
Свободный день 🎉
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{todayEvents.map(ev => (
|
||||||
|
<div key={ev.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 32, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{ev.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2, display: 'flex', gap: 6 }}>
|
||||||
|
<span>{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)} — ${formatEventTime(ev.end)}`}</span>
|
||||||
|
<span style={{ color: ev.color }}>{ev.ownerName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weather Widget */}
|
||||||
|
{weather && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--card-bg)',
|
||||||
|
border: '1px solid var(--card-border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '18px 20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
|
||||||
|
🌤️ Погода
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 44 }}>{getWeatherEmoji(weather.desc)}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 32, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{weather.temp}°C</div>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4 }}>{weather.desc}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>
|
||||||
|
💧 {weather.humidity}% · 💨 {weather.windSpeed} км/ч · Ощущается {weather.feelsLike}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forecast */}
|
||||||
|
{weather.forecast && weather.forecast.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{weather.forecast.slice(0, 3).map(day => {
|
||||||
|
const d = new Date(day.date)
|
||||||
|
const label = d.toLocaleDateString('ru-RU', { weekday: 'short' })
|
||||||
|
return (
|
||||||
|
<div key={day.date} style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '10px 8px',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 20, marginBottom: 4 }}>{getWeatherEmoji(day.desc)}</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sensors Widget */}
|
||||||
|
{sensors && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--card-bg)',
|
||||||
|
border: '1px solid var(--card-border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '18px 20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 14 }}>
|
||||||
|
📊 Датчики квартиры
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' },
|
||||||
|
{ label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' },
|
||||||
|
{ label: 'PM2.5', value: `${sensors.pm25} μg`, icon: '💨' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: '12px 10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 6 }}>{s.icon}</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [tab, setTab] = useState<Tab>('home')
|
const [tab, setTab] = useState<Tab>('home')
|
||||||
const [activeRoom, setActiveRoom] = useState('living')
|
const [activeRoom, setActiveRoom] = useState('living')
|
||||||
@@ -97,7 +265,6 @@ export default function HomePage() {
|
|||||||
const [sensors, setSensors] = useState<SensorData | null>(null)
|
const [sensors, setSensors] = useState<SensorData | null>(null)
|
||||||
const [haStates, setHaStates] = useState<HaStates>({})
|
const [haStates, setHaStates] = useState<HaStates>({})
|
||||||
|
|
||||||
// Load weather
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -111,7 +278,6 @@ export default function HomePage() {
|
|||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load HA states + sensors
|
|
||||||
const loadHA = useCallback(async () => {
|
const loadHA = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/ha')
|
const r = await fetch('/api/ha')
|
||||||
@@ -142,43 +308,37 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100dvh',
|
height: '100dvh',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
background: 'var(--bg)',
|
background: 'var(--bg)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<Sidebar active={tab} onChange={setTab} />
|
<Sidebar active={tab} onChange={setTab} />
|
||||||
|
|
||||||
<main
|
<main style={{
|
||||||
style={{
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<TopBar weather={weather} sensors={sensors} />
|
<TopBar weather={weather} sensors={sensors} />
|
||||||
|
|
||||||
{tab === 'home' && (
|
{tab === 'home' && <HomeTab weather={weather} sensors={sensors} />}
|
||||||
|
|
||||||
|
{tab === 'devices' && (
|
||||||
<>
|
<>
|
||||||
<RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} />
|
<RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} />
|
||||||
|
<div style={{
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
WebkitOverflowScrolling: 'touch' as any,
|
WebkitOverflowScrolling: 'touch' as any,
|
||||||
padding: '16px 20px 24px',
|
padding: '16px 20px 24px',
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{devicesInRoom.length === 0 ? (
|
{devicesInRoom.length === 0 ? (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -186,19 +346,12 @@ export default function HomePage() {
|
|||||||
height: 200,
|
height: 200,
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<span style={{ fontSize: 40 }}>🏠</span>
|
<span style={{ fontSize: 40 }}>🏠</span>
|
||||||
<span style={{ fontSize: 15 }}>Устройства не добавлены</span>
|
<span style={{ fontSize: 15 }}>Устройства не добавлены</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{devicesInRoom.map(device => (
|
{devicesInRoom.map(device => (
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
key={device.id}
|
key={device.id}
|
||||||
@@ -218,80 +371,10 @@ export default function HomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'rooms' && (
|
{tab === 'calendar' && <CalendarTab />}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 12,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 48 }}>🏠</span>
|
|
||||||
<span style={{ fontSize: 16 }}>Управление комнатами</span>
|
|
||||||
<span style={{ fontSize: 13 }}>Скоро</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'sensors' && sensors && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
padding: '20px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Датчики</h2>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
|
||||||
{[
|
|
||||||
{ label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' },
|
|
||||||
{ label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' },
|
|
||||||
{ label: 'PM2.5', value: `${sensors.pm25} μg/m³`, icon: '💨' },
|
|
||||||
].map(s => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
style={{
|
|
||||||
background: 'var(--card-bg)',
|
|
||||||
border: '1px solid var(--card-border)',
|
|
||||||
borderRadius: 18,
|
|
||||||
padding: 20,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 28 }}>{s.icon}</span>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{s.value}</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{s.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'sensors' && !sensors && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка датчиков...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'settings' && (
|
{tab === 'settings' && (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -299,8 +382,7 @@ export default function HomePage() {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<span style={{ fontSize: 48 }}>⚙️</span>
|
<span style={{ fontSize: 48 }}>⚙️</span>
|
||||||
<span style={{ fontSize: 16 }}>Настройки</span>
|
<span style={{ fontSize: 16 }}>Настройки</span>
|
||||||
<span style={{ fontSize: 13 }}>Скоро</span>
|
<span style={{ fontSize: 13 }}>Скоро</span>
|
||||||
|
|||||||
285
components/CalendarTab.tsx
Normal file
285
components/CalendarTab.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface CalendarEvent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
allDay: boolean
|
||||||
|
description: string | null
|
||||||
|
location: string | null
|
||||||
|
owner: string
|
||||||
|
ownerName: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayHeader(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + (dateStr.length === 10 ? 'T00:00:00' : ''))
|
||||||
|
return d.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(events: CalendarEvent[]): Record<string, CalendarEvent[]> {
|
||||||
|
const groups: Record<string, CalendarEvent[]> = {}
|
||||||
|
for (const ev of events) {
|
||||||
|
const dateKey = ev.start.substring(0, 10)
|
||||||
|
if (!groups[dateKey]) groups[dateKey] = []
|
||||||
|
groups[dateKey].push(ev)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventCard({ event }: { event: CalendarEvent }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 3, borderRadius: 2, background: event.color, alignSelf: 'stretch', minHeight: 36, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>
|
||||||
|
{event.allDay ? 'Весь день' : `${formatTime(event.start)} — ${formatTime(event.end)}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: event.color, fontWeight: 500 }}>{event.ownerName}</span>
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2, opacity: 0.7 }}>📍 {event.location}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineView({ range }: { range: 'today' | 'week' }) {
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetch(`/api/calendar?range=${range}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setEvents(d.events || []))
|
||||||
|
.catch(() => setEvents([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [range])
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-secondary)' }}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (events.length === 0) return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, gap: 8, color: 'var(--text-secondary)' }}>
|
||||||
|
<span style={{ fontSize: 40 }}>🎉</span>
|
||||||
|
<span style={{ fontSize: 15 }}>Свободный день!</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const grouped = groupByDate(events)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 24px' }}>
|
||||||
|
{Object.entries(grouped).map(([dateKey, dayEvents]) => (
|
||||||
|
<div key={dateKey} style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 10, textTransform: 'capitalize', letterSpacing: '0.02em' }}>
|
||||||
|
{formatDayHeader(dateKey)}
|
||||||
|
</div>
|
||||||
|
{dayEvents.map(ev => <EventCard key={ev.id} event={ev} />)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthView() {
|
||||||
|
const [year, setYear] = useState(() => new Date().getFullYear())
|
||||||
|
const [month, setMonth] = useState(() => new Date().getMonth())
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedDay, setSelectedDay] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetch('/api/calendar?range=month')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setEvents(d.events || []))
|
||||||
|
.catch(() => setEvents([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [year, month])
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
const startDow = (firstDay.getDay() + 6) % 7 // Mon=0
|
||||||
|
const totalCells = Math.ceil((startDow + lastDay.getDate()) / 7) * 7
|
||||||
|
|
||||||
|
const monthName = firstDay.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
|
const eventsByDate: Record<string, CalendarEvent[]> = {}
|
||||||
|
for (const ev of events) {
|
||||||
|
const dk = ev.start.substring(0, 10)
|
||||||
|
if (!eventsByDate[dk]) eventsByDate[dk] = []
|
||||||
|
eventsByDate[dk].push(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (month === 0) { setYear(y => y - 1); setMonth(11) }
|
||||||
|
else setMonth(m => m - 1)
|
||||||
|
setSelectedDay(null)
|
||||||
|
}
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (month === 11) { setYear(y => y + 1); setMonth(0) }
|
||||||
|
else setMonth(m => m + 1)
|
||||||
|
setSelectedDay(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEvents = selectedDay ? (eventsByDate[selectedDay] || []) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* Month header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<button onClick={prevMonth} style={{ padding: 8, borderRadius: 10, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--text-primary)' }}>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
|
||||||
|
<button onClick={nextMonth} style={{ padding: 8, borderRadius: 10, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--text-primary)' }}>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day of week headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, textAlign: 'center' }}>
|
||||||
|
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
|
||||||
|
<div key={d} style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, paddingBottom: 4 }}>{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 14 }}>Загрузка...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
|
||||||
|
{Array.from({ length: totalCells }).map((_, idx) => {
|
||||||
|
const dayNum = idx - startDow + 1
|
||||||
|
if (dayNum < 1 || dayNum > lastDay.getDate()) {
|
||||||
|
return <div key={idx} style={{ height: 44 }} />
|
||||||
|
}
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(dayNum).padStart(2, '0')}`
|
||||||
|
const hasEvents = !!eventsByDate[dateStr]
|
||||||
|
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === dayNum
|
||||||
|
const isSelected = selectedDay === dateStr
|
||||||
|
const dayEvents = eventsByDate[dateStr] || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setSelectedDay(isSelected ? null : dateStr)}
|
||||||
|
style={{
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 3,
|
||||||
|
background: isSelected ? 'rgba(0,212,255,0.15)' : isToday ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.02)',
|
||||||
|
border: isSelected ? '1px solid rgba(0,212,255,0.4)' : isToday ? '1px solid rgba(255,255,255,0.15)' : '1px solid rgba(255,255,255,0.04)',
|
||||||
|
color: isToday ? '#00d4ff' : 'var(--text-primary)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isToday ? 700 : 400,
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{dayNum}</span>
|
||||||
|
{hasEvents && (
|
||||||
|
<div style={{ display: 'flex', gap: 2 }}>
|
||||||
|
{dayEvents.slice(0, 3).map((ev, i) => (
|
||||||
|
<div key={i} style={{ width: 5, height: 5, borderRadius: '50%', background: ev.color }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected day events */}
|
||||||
|
{selectedDay && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 10, textTransform: 'capitalize' }}>
|
||||||
|
{formatDayHeader(selectedDay)}
|
||||||
|
</div>
|
||||||
|
{selectedEvents.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 14, padding: '16px 0' }}>
|
||||||
|
Событий нет 🎉
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
selectedEvents.map(ev => <EventCard key={ev.id} event={ev} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarTab() {
|
||||||
|
const [view, setView] = useState<'today' | 'week' | 'month'>('today')
|
||||||
|
|
||||||
|
const viewOptions: { id: typeof view; label: string }[] = [
|
||||||
|
{ id: 'today', label: 'Сегодня' },
|
||||||
|
{ id: 'week', label: 'Неделя' },
|
||||||
|
{ id: 'month', label: 'Месяц' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* View switcher */}
|
||||||
|
<div style={{ padding: '12px 20px 0', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'inline-flex', background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 3, border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
{viewOptions.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => setView(opt.id)}
|
||||||
|
style={{
|
||||||
|
padding: '7px 18px',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: view === opt.id ? 'rgba(0,212,255,0.15)' : 'transparent',
|
||||||
|
border: view === opt.id ? '1px solid rgba(0,212,255,0.25)' : '1px solid transparent',
|
||||||
|
color: view === opt.id ? '#00d4ff' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(view === 'today' || view === 'week') && <TimelineView range={view} />}
|
||||||
|
{view === 'month' && <MonthView />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Home, LayoutGrid, Thermometer, Settings } from 'lucide-react'
|
import { Home, Cpu, CalendarDays, Settings } from 'lucide-react'
|
||||||
|
|
||||||
type Tab = 'home' | 'rooms' | 'sensors' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'settings'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
active: Tab
|
active: Tab
|
||||||
onChange: (tab: Tab) => void
|
onChange: (tab: Tab) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: { id: Tab; icon: any }[] = [
|
const navItems: { id: Tab; icon: any; label: string }[] = [
|
||||||
{ id: 'home', icon: Home },
|
{ id: 'home', icon: Home, label: 'Главная' },
|
||||||
{ id: 'rooms', icon: LayoutGrid },
|
{ id: 'devices', icon: Cpu, label: 'Устройства' },
|
||||||
{ id: 'sensors', icon: Thermometer },
|
{ id: 'calendar', icon: CalendarDays, label: 'Календарь' },
|
||||||
{ id: 'settings', icon: Settings },
|
{ id: 'settings', icon: Settings, label: 'Настройки' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Sidebar({ active, onChange }: SidebarProps) {
|
export default function Sidebar({ active, onChange }: SidebarProps) {
|
||||||
@@ -56,12 +56,13 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
{navItems.map(({ id, icon: Icon }) => {
|
{navItems.map(({ id, icon: Icon, label }) => {
|
||||||
const isActive = active === id
|
const isActive = active === id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
|
title={label}
|
||||||
style={{
|
style={{
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
2251
package-lock.json
generated
Normal file
2251
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,13 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^11.1.7",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
|
"lucide-react": "^0.376.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18"
|
||||||
"framer-motion": "^11.1.7",
|
|
||||||
"lucide-react": "^0.376.0",
|
|
||||||
"clsx": "^2.1.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
Reference in New Issue
Block a user