diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7c8ba53..5cda35e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,17 +8,19 @@ jobs: deploy: runs-on: self-hosted container: - image: docker:24-cli + image: alpine:3.19 volumes: - /opt/digital-home:/opt/digital-home - /var/run/docker.sock:/var/run/docker.sock steps: - - name: Build and restart + - name: Build and deploy run: | + apk add --no-cache docker-cli git cd /opt/digital-home/smart-home-tablet + git config --global --add safe.directory /opt/digital-home/smart-home-tablet git pull origin main docker build -t smart-home-tablet:latest . docker stop tablet-yfh53kixpwkjlo4zibglx4n2 || true docker rm tablet-yfh53kixpwkjlo4zibglx4n2 || true docker run -d --name tablet-yfh53kixpwkjlo4zibglx4n2 --network coolify -p 3006:3000 --restart unless-stopped --label traefik.enable=true --label 'traefik.http.routers.tablet.rule=Host(`tablet.digital-home.site`)' --label traefik.http.routers.tablet.entrypoints=https --label traefik.http.routers.tablet.tls=true --label traefik.http.routers.tablet.tls.certresolver=letsencrypt --label traefik.http.services.tablet.loadbalancer.server.port=3000 --env-file /opt/digital-home/smart-home-tablet/.tablet.env smart-home-tablet:latest - echo 'Done' + echo "Deploy done" diff --git a/app/api/calendar/route.ts b/app/api/calendar/route.ts index 4b051c0..4f30916 100644 --- a/app/api/calendar/route.ts +++ b/app/api/calendar/route.ts @@ -4,22 +4,24 @@ import { google } from 'googleapis' import * as fs from 'fs' import * as path from 'path' -function getAuth() { - // Service account JSON (inline or from file) +function getAuth(readonly = true) { + const scopes = readonly + ? ['https://www.googleapis.com/auth/calendar.readonly'] + : ['https://www.googleapis.com/auth/calendar'] + const saJson = process.env.GOOGLE_SA_JSON if (saJson) { const sa = JSON.parse(saJson) return new google.auth.GoogleAuth({ credentials: sa, - scopes: ['https://www.googleapis.com/auth/calendar.readonly'], + scopes, }) } - // Fallback: file const saPath = path.join(process.cwd(), 'google-sa.json') if (fs.existsSync(saPath)) { return new google.auth.GoogleAuth({ keyFile: saPath, - scopes: ['https://www.googleapis.com/auth/calendar.readonly'], + scopes, }) } return null @@ -29,7 +31,7 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url) const range = searchParams.get('range') || 'today' - const auth = getAuth() + const auth = getAuth(true) if (!auth) { return NextResponse.json({ events: [], error: 'not_configured' }) } @@ -48,8 +50,11 @@ export async function GET(req: Request) { timeMin = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString() timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString() } else { - timeMin = new Date(now.getFullYear(), now.getMonth(), 1).toISOString() - timeMax = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59).toISOString() + // month — support year/month query params + const targetYear = parseInt(searchParams.get('year') || String(now.getFullYear())) + const targetMonth = parseInt(searchParams.get('month') || String(now.getMonth())) + timeMin = new Date(targetYear, targetMonth, 1).toISOString() + timeMax = new Date(targetYear, targetMonth + 1, 0, 23, 59).toISOString() } const calendarClient = google.calendar({ version: 'v3', auth: auth as any }) @@ -97,3 +102,46 @@ export async function GET(req: Request) { return NextResponse.json({ events: allEvents, errors: errors.length ? errors : undefined, fetchedAt: new Date().toISOString() }) } + +export async function POST(req: Request) { + const body = await req.json() + const { title, date, startTime, endTime, allDay } = body + + const auth = getAuth(false) + if (!auth) return NextResponse.json({ error: 'not_configured' }, { status: 500 }) + + const calendarClient = google.calendar({ version: 'v3', auth: auth as any }) + + const daniilCalendarId = process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com' + + let start: any, end: any + if (allDay) { + start = { date } + end = { date } + } else { + start = { dateTime: `${date}T${startTime}:00`, timeZone: 'Europe/Moscow' } + end = { dateTime: `${date}T${endTime}:00`, timeZone: 'Europe/Moscow' } + } + + try { + const res = await calendarClient.events.insert({ + calendarId: daniilCalendarId, + requestBody: { summary: title, start, end }, + }) + const e = res.data + return NextResponse.json({ + event: { + id: e.id, + title: e.summary || title, + start: e.start?.dateTime || e.start?.date, + end: e.end?.dateTime || e.end?.date, + allDay: !e.start?.dateTime, + owner: 'daniil', + ownerName: 'Даниил', + color: '#6366f1', + } + }) + } catch (err: any) { + return NextResponse.json({ error: err.message || 'Failed to create event' }, { status: 500 }) + } +} diff --git a/components/CalendarTab.tsx b/components/CalendarTab.tsx index 79a142f..92cca14 100644 --- a/components/CalendarTab.tsx +++ b/components/CalendarTab.tsx @@ -1,7 +1,6 @@ 'use client' - -import { useState, useEffect, useCallback } from 'react' -import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useState, useEffect } from 'react' +import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin } from 'lucide-react' interface CalendarEvent { id: string @@ -16,269 +15,303 @@ interface CalendarEvent { color: string } -function formatTime(iso: string): string { - const d = new Date(iso) - return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) -} +const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] +const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'] -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 AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; onClose: () => void; onSaved: (e: any) => void }) { + const [title, setTitle] = useState('') + const [date, setDate] = useState(defaultDate) + const [startTime, setStartTime] = useState('10:00') + const [endTime, setEndTime] = useState('11:00') + const [allDay, setAllDay] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') -function groupByDate(events: CalendarEvent[]): Record { - const groups: Record = {} - for (const ev of events) { - const dateKey = ev.start.substring(0, 10) - if (!groups[dateKey]) groups[dateKey] = [] - groups[dateKey].push(ev) + const save = async () => { + if (!title.trim()) { setError('Введите название'); return } + setSaving(true) + setError('') + try { + 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 d = await r.json() + if (d.error) throw new Error(d.error) + onSaved(d.event) + } catch (e: any) { + setError(e.message || 'Ошибка сохранения') + setSaving(false) + } } - return groups -} - -function EventCard({ event }: { event: CalendarEvent }) { - return ( -
-
-
-
- {event.title} -
-
- - {event.allDay ? 'Весь день' : `${formatTime(event.start)} — ${formatTime(event.end)}`} - - {event.ownerName} -
- {event.location && ( -
📍 {event.location}
- )} -
-
- ) -} - -function TimelineView({ range }: { range: 'today' | 'week' }) { - const [events, setEvents] = useState([]) - 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 ( -
- Загрузка... -
- ) - - if (events.length === 0) return ( -
- 🎉 - Свободный день! -
- ) - - const grouped = groupByDate(events) return ( -
- {Object.entries(grouped).map(([dateKey, dayEvents]) => ( -
-
- {formatDayHeader(dateKey)} -
- {dayEvents.map(ev => )} +
+
e.stopPropagation()}> +
+ Новое событие +
- ))} -
- ) -} -function MonthView() { - const [year, setYear] = useState(() => new Date().getFullYear()) - const [month, setMonth] = useState(() => new Date().getMonth()) - const [events, setEvents] = useState([]) - const [loading, setLoading] = useState(true) - const [selectedDay, setSelectedDay] = useState(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 = {} - 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 ( -
- {/* Month header */} -
- - {monthName} - -
- - {/* Day of week headers */} -
- {['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => ( -
{d}
- ))} -
- - {/* Calendar grid */} - {loading ? ( -
Загрузка...
- ) : ( -
- {Array.from({ length: totalCells }).map((_, idx) => { - const dayNum = idx - startDow + 1 - if (dayNum < 1 || dayNum > lastDay.getDate()) { - return
- } - 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 ( - - ) - })} -
- )} - - {/* Selected day events */} - {selectedDay && ( -
-
- {formatDayHeader(selectedDay)} -
- {selectedEvents.length === 0 ? ( -
- Событий нет 🎉 +
+ setTitle(e.target.value)} + placeholder="Название события" + autoFocus + style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} + /> + setDate(e.target.value)} + style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} + /> + {!allDay && ( +
+ setStartTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} /> + setEndTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} />
- ) : ( - selectedEvents.map(ev => ) )} + + {error &&
{error}
} +
- )} +
) } export default function CalendarTab() { - const [view, setView] = useState<'week' | 'month'>('week') + const [year, setYear] = useState(new Date().getFullYear()) + const [month, setMonth] = useState(new Date().getMonth()) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedEvent, setSelectedEvent] = useState(null) + const [showAddModal, setShowAddModal] = useState(false) + const [addDate, setAddDate] = useState('') - const viewOptions: { id: 'week' | 'month'; label: string }[] = [ - { id: 'week', label: 'Неделя' }, - { id: 'month', label: 'Месяц' }, - ] + useEffect(() => { + setLoading(true) + fetch('/api/calendar?range=month&year=' + year + '&month=' + month) + .then(r => r.json()) + .then(d => { setEvents(d.events || []); setLoading(false) }) + .catch(() => setLoading(false)) + }, [year, month]) + + // Upcoming events (next 30 days) + const upcoming = events + .filter(e => new Date(e.start) >= new Date()) + .slice(0, 6) + + // Build calendar grid + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + // Monday-based week: 0=Mon, 6=Sun + const startOffset = (firstDay.getDay() + 6) % 7 + const totalCells = Math.ceil((startOffset + lastDay.getDate()) / 7) * 7 + const cells: (number | null)[] = [] + for (let i = 0; i < totalCells; i++) { + const dayNum = i - startOffset + 1 + cells.push(dayNum >= 1 && dayNum <= lastDay.getDate() ? dayNum : null) + } + + const getEventsForDay = (day: number) => { + return events.filter(e => { + const d = new Date(e.start) + return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day + }) + } + + 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 today = new Date() return ( -
- {/* View switcher */} -
-
- {viewOptions.map(opt => ( - + + {MONTHS[month]} {year} + + +
+ +
+ + {/* Weekday headers */} +
+ {WEEKDAYS.map(d => ( +
{d}
))}
+ + {/* Calendar cells */} +
+ {cells.map((day, idx) => { + if (!day) return
+ const dayEvents = getEventsForDay(day) + const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day + return ( +
{ + if (dayEvents.length === 1) setSelectedEvent(dayEvents[0]) + else if (dayEvents.length === 0) { + setAddDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`) + setShowAddModal(true) + } + }} + style={{ + borderRadius: 8, + padding: '4px 3px', + background: isToday ? 'rgba(99,102,241,0.12)' : 'rgba(255,255,255,0.02)', + border: isToday ? '1px solid rgba(99,102,241,0.35)' : '1px solid transparent', + cursor: 'pointer', + minHeight: 52, + display: 'flex', + flexDirection: 'column', + gap: 2, + touchAction: 'manipulation', + }} + > + {day} + {dayEvents.slice(0, 2).map(e => ( +
{ ev.stopPropagation(); setSelectedEvent(e) }} + style={{ + fontSize: 10, + fontWeight: 600, + background: e.color + '33', + border: `1px solid ${e.color}55`, + color: e.color, + borderRadius: 4, + padding: '1px 4px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', + }} + > + {e.title} +
+ ))} + {dayEvents.length > 2 && ( +
+{dayEvents.length - 2}
+ )} +
+ ) + })} +
- {view === 'week' && } - {view === 'month' && } + {/* Right panel: upcoming events */} +
+
Ближайшие
+ {upcoming.length === 0 && !loading && ( +
Нет событий
+ )} + {upcoming.map(e => { + const d = new Date(e.start) + return ( +
setSelectedEvent(e)} + style={{ + borderRadius: 12, + padding: '10px 12px', + background: e.color + '18', + border: `1px solid ${e.color}33`, + cursor: 'pointer', + touchAction: 'manipulation', + }} + > +
+
+ {d.getDate()} +
+
{MONTHS[d.getMonth()].slice(0, 3)}
+
+
{e.title}
+
+ {e.allDay ? 'Весь день' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +
+
{e.ownerName}
+
+ ) + })} +
+ + {/* Event detail modal */} + {selectedEvent && ( +
setSelectedEvent(null)}> +
e.stopPropagation()}> +
+
+
+
{selectedEvent.title}
+
{selectedEvent.ownerName}
+
+ +
+
+
+ + {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' })}` + } +
+ {selectedEvent.location && ( +
+ {selectedEvent.location} +
+ )} + {selectedEvent.description && ( +
{selectedEvent.description}
+ )} +
+
+
+ )} + + {/* Add event modal */} + {showAddModal && ( + setShowAddModal(false)} + onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }} + /> + )}
) }