fix: CI pipeline alpine+docker-cli, calendar redesign + POST API
Some checks failed
Deploy / deploy (push) Failing after 4s
Some checks failed
Deploy / deploy (push) Failing after 4s
This commit is contained in:
@@ -8,17 +8,19 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
container:
|
container:
|
||||||
image: docker:24-cli
|
image: alpine:3.19
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/digital-home:/opt/digital-home
|
- /opt/digital-home:/opt/digital-home
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
steps:
|
steps:
|
||||||
- name: Build and restart
|
- name: Build and deploy
|
||||||
run: |
|
run: |
|
||||||
|
apk add --no-cache docker-cli git
|
||||||
cd /opt/digital-home/smart-home-tablet
|
cd /opt/digital-home/smart-home-tablet
|
||||||
|
git config --global --add safe.directory /opt/digital-home/smart-home-tablet
|
||||||
git pull origin main
|
git pull origin main
|
||||||
docker build -t smart-home-tablet:latest .
|
docker build -t smart-home-tablet:latest .
|
||||||
docker stop tablet-yfh53kixpwkjlo4zibglx4n2 || true
|
docker stop tablet-yfh53kixpwkjlo4zibglx4n2 || true
|
||||||
docker rm 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
|
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"
|
||||||
|
|||||||
@@ -4,22 +4,24 @@ import { google } from 'googleapis'
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
|
||||||
function getAuth() {
|
function getAuth(readonly = true) {
|
||||||
// Service account JSON (inline or from file)
|
const scopes = readonly
|
||||||
|
? ['https://www.googleapis.com/auth/calendar.readonly']
|
||||||
|
: ['https://www.googleapis.com/auth/calendar']
|
||||||
|
|
||||||
const saJson = process.env.GOOGLE_SA_JSON
|
const saJson = process.env.GOOGLE_SA_JSON
|
||||||
if (saJson) {
|
if (saJson) {
|
||||||
const sa = JSON.parse(saJson)
|
const sa = JSON.parse(saJson)
|
||||||
return new google.auth.GoogleAuth({
|
return new google.auth.GoogleAuth({
|
||||||
credentials: sa,
|
credentials: sa,
|
||||||
scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
|
scopes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Fallback: file
|
|
||||||
const saPath = path.join(process.cwd(), 'google-sa.json')
|
const saPath = path.join(process.cwd(), 'google-sa.json')
|
||||||
if (fs.existsSync(saPath)) {
|
if (fs.existsSync(saPath)) {
|
||||||
return new google.auth.GoogleAuth({
|
return new google.auth.GoogleAuth({
|
||||||
keyFile: saPath,
|
keyFile: saPath,
|
||||||
scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
|
scopes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -29,7 +31,7 @@ export async function GET(req: Request) {
|
|||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const range = searchParams.get('range') || 'today'
|
const range = searchParams.get('range') || 'today'
|
||||||
|
|
||||||
const auth = getAuth()
|
const auth = getAuth(true)
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
return NextResponse.json({ events: [], error: 'not_configured' })
|
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()
|
timeMin = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
|
||||||
timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString()
|
timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString()
|
||||||
} else {
|
} else {
|
||||||
timeMin = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
|
// month — support year/month query params
|
||||||
timeMax = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59).toISOString()
|
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 })
|
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() })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin } from 'lucide-react'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
||||||
|
|
||||||
interface CalendarEvent {
|
interface CalendarEvent {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,269 +15,303 @@ interface CalendarEvent {
|
|||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||||
const d = new Date(iso)
|
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
|
||||||
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayHeader(dateStr: string): string {
|
function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; onClose: () => void; onSaved: (e: any) => void }) {
|
||||||
const d = new Date(dateStr + (dateStr.length === 10 ? 'T00:00:00' : ''))
|
const [title, setTitle] = useState('')
|
||||||
return d.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })
|
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<string, CalendarEvent[]> {
|
const save = async () => {
|
||||||
const groups: Record<string, CalendarEvent[]> = {}
|
if (!title.trim()) { setError('Введите название'); return }
|
||||||
for (const ev of events) {
|
setSaving(true)
|
||||||
const dateKey = ev.start.substring(0, 10)
|
setError('')
|
||||||
if (!groups[dateKey]) groups[dateKey] = []
|
try {
|
||||||
groups[dateKey].push(ev)
|
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 (
|
|
||||||
<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 (
|
return (
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 24px' }}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
|
||||||
{Object.entries(grouped).map(([dateKey, dayEvents]) => (
|
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 340, width: '100%' }} onClick={e => e.stopPropagation()}>
|
||||||
<div key={dateKey} style={{ marginBottom: 20 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 10, textTransform: 'capitalize', letterSpacing: '0.02em' }}>
|
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span>
|
||||||
{formatDayHeader(dateKey)}
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}><X size={18} /></button>
|
||||||
</div>
|
|
||||||
{dayEvents.map(ev => <EventCard key={ev.id} event={ev} />)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MonthView() {
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
const [year, setYear] = useState(() => new Date().getFullYear())
|
<input
|
||||||
const [month, setMonth] = useState(() => new Date().getMonth())
|
value={title}
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
onChange={e => setTitle(e.target.value)}
|
||||||
const [loading, setLoading] = useState(true)
|
placeholder="Название события"
|
||||||
const [selectedDay, setSelectedDay] = useState<string | null>(null)
|
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' }}
|
||||||
useEffect(() => {
|
/>
|
||||||
setLoading(true)
|
<input
|
||||||
fetch('/api/calendar?range=month')
|
type="date"
|
||||||
.then(r => r.json())
|
value={date}
|
||||||
.then(d => setEvents(d.events || []))
|
onChange={e => setDate(e.target.value)}
|
||||||
.catch(() => setEvents([]))
|
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' }}
|
||||||
.finally(() => setLoading(false))
|
/>
|
||||||
}, [year, month])
|
{!allDay && (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
const today = new Date()
|
<input type="time" value={startTime} onChange={e => 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' }} />
|
||||||
const firstDay = new Date(year, month, 1)
|
<input type="time" value={endTime} onChange={e => 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' }} />
|
||||||
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>
|
</div>
|
||||||
) : (
|
|
||||||
selectedEvents.map(ev => <EventCard key={ev.id} event={ev} />)
|
|
||||||
)}
|
)}
|
||||||
|
<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)} />
|
||||||
|
Весь день
|
||||||
|
</label>
|
||||||
|
{error && <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div>}
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ padding: '11px', borderRadius: 12, background: saving ? 'rgba(99,102,241,0.3)' : 'rgba(99,102,241,0.5)', border: '1px solid rgba(99,102,241,0.5)', color: '#a5b4fc', fontSize: 14, fontWeight: 600, cursor: saving ? 'default' : 'pointer', touchAction: 'manipulation' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Создать событие'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarTab() {
|
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<CalendarEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [addDate, setAddDate] = useState<string>('')
|
||||||
|
|
||||||
const viewOptions: { id: 'week' | 'month'; label: string }[] = [
|
useEffect(() => {
|
||||||
{ id: 'week', label: 'Неделя' },
|
setLoading(true)
|
||||||
{ id: 'month', label: 'Месяц' },
|
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 (
|
return (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '12px 16px 16px', gap: 16 }}>
|
||||||
{/* View switcher */}
|
{/* Main calendar grid */}
|
||||||
<div style={{ padding: '12px 20px 0', flexShrink: 0 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 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)' }}>
|
{/* Header */}
|
||||||
{viewOptions.map(opt => (
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<button
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
key={opt.id}
|
<button onClick={prevMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
onClick={() => setView(opt.id)}
|
<ChevronLeft size={16} />
|
||||||
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>
|
</button>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', minWidth: 160, textAlign: 'center' }}>
|
||||||
|
{MONTHS[month]} {year}
|
||||||
|
</span>
|
||||||
|
<button onClick={nextMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(99,102,241,0.2)', border: '1px solid rgba(99,102,241,0.4)', color: '#a5b4fc', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
Событие
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekday headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
|
||||||
|
{WEEKDAYS.map(d => (
|
||||||
|
<div key={d} style={{ textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', padding: '4px 0' }}>{d}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar cells */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 2 }}>
|
||||||
|
{cells.map((day, idx) => {
|
||||||
|
if (!day) return <div key={idx} />
|
||||||
|
const dayEvents = getEventsForDay(day)
|
||||||
|
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
onClick={() => {
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: isToday ? 700 : 400, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', textAlign: 'right', paddingRight: 3 }}>{day}</span>
|
||||||
|
{dayEvents.slice(0, 2).map(e => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
onClick={ev => { 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}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 2 && (
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-secondary)', textAlign: 'center' }}>+{dayEvents.length - 2}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'week' && <TimelineView range={view} />}
|
{/* Right panel: upcoming events */}
|
||||||
{view === 'month' && <MonthView />}
|
<div style={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Ближайшие</div>
|
||||||
|
{upcoming.length === 0 && !loading && (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Нет событий</div>
|
||||||
|
)}
|
||||||
|
{upcoming.map(e => {
|
||||||
|
const d = new Date(e.start)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
onClick={() => setSelectedEvent(e)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: e.color + '18',
|
||||||
|
border: `1px solid ${e.color}33`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: 8, background: e.color + '33', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
|
||||||
|
{e.allDay ? 'Весь день' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: e.color, marginTop: 3, fontWeight: 500 }}>{e.ownerName}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event detail modal */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setSelectedEvent(null)}>
|
||||||
|
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||||
|
<div style={{ width: 4, height: 40, borderRadius: 2, background: selectedEvent.color, marginRight: 12, flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedEvent.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: selectedEvent.color, fontWeight: 500, marginTop: 2 }}>{selectedEvent.ownerName}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedEvent(null)} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 4 }}><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||||
|
<Clock size={14} />
|
||||||
|
{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' })}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{selectedEvent.location && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||||
|
<MapPin size={14} /> {selectedEvent.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedEvent.description && (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.5 }}>{selectedEvent.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add event modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<AddEventModal
|
||||||
|
defaultDate={addDate}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user