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:
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'
|
||||
|
||||
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 {
|
||||
active: Tab
|
||||
onChange: (tab: Tab) => void
|
||||
}
|
||||
|
||||
const navItems: { id: Tab; icon: any }[] = [
|
||||
{ id: 'home', icon: Home },
|
||||
{ id: 'rooms', icon: LayoutGrid },
|
||||
{ id: 'sensors', icon: Thermometer },
|
||||
{ id: 'settings', icon: Settings },
|
||||
const navItems: { id: Tab; icon: any; label: string }[] = [
|
||||
{ id: 'home', icon: Home, label: 'Главная' },
|
||||
{ id: 'devices', icon: Cpu, label: 'Устройства' },
|
||||
{ id: 'calendar', icon: CalendarDays, label: 'Календарь' },
|
||||
{ id: 'settings', icon: Settings, label: 'Настройки' },
|
||||
]
|
||||
|
||||
export default function Sidebar({ active, onChange }: SidebarProps) {
|
||||
@@ -56,12 +56,13 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
{navItems.map(({ id, icon: Icon }) => {
|
||||
{navItems.map(({ id, icon: Icon, label }) => {
|
||||
const isActive = active === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onChange(id)}
|
||||
title={label}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
|
||||
Reference in New Issue
Block a user