Files
smart-home-tablet/components/CalendarTab.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +00:00

904 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useMemo } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2, Eye, EyeOff, CalendarDays, User, AlignLeft, List } 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
}
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
function formatFullDate(iso: string): string {
return new Date(iso).toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
}
// ————— Add Event Modal —————
const CALENDAR_OPTIONS = [
{ owner: 'daniil', name: 'Даниил', color: '#6366f1' },
{ owner: 'sveta', name: 'Света', color: '#ec4899' },
]
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 [owner, setOwner] = useState('daniil')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const selectedCal = CALENDAR_OPTIONS.find(c => c.owner === owner) || CALENDAR_OPTIONS[0]
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, owner }
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 (
<div data-swipe-ignore style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(12px)',
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
}} onClick={onClose}>
<div style={{
background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
width: 400, maxWidth: '90vw', overflow: 'hidden',
boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{
padding: '22px 24px 18px',
borderBottom: '1px solid rgba(255,255,255,0.05)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
Новое событие
</span>
<button onClick={onClose} aria-label="Закрыть" style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-secondary)',
}}>
<X size={18} />
</button>
</div>
{/* Body */}
<div style={{ padding: '20px 24px 24px' }}>
{/* Calendar selector */}
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{CALENDAR_OPTIONS.map(cal => {
const sel = owner === cal.owner
return (
<button key={cal.owner} onClick={() => setOwner(cal.owner)} style={{
flex: 1, padding: '11px 0', borderRadius: 12,
background: sel ? `${cal.color}15` : 'rgba(255,255,255,0.03)',
border: `1.5px solid ${sel ? cal.color + '40' : 'rgba(255,255,255,0.06)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'all 0.2s ease',
}}>
<div style={{
width: 10, height: 10, borderRadius: '50%',
background: sel ? cal.color : 'rgba(255,255,255,0.15)',
boxShadow: sel ? `0 0 8px ${cal.color}50` : 'none',
transition: 'all 0.2s ease',
}} />
<span style={{
fontSize: 14, fontWeight: sel ? 600 : 400,
color: sel ? cal.color : 'var(--text-secondary)',
}}>{cal.name}</span>
</button>
)
})}
</div>
{/* Title */}
<input
value={title} onChange={e => setTitle(e.target.value)}
placeholder="Название события"
autoFocus
style={{
width: '100%', padding: '15px 18px', borderRadius: 14, boxSizing: 'border-box' as any,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 16, fontWeight: 500,
outline: 'none', fontFamily: 'inherit',
marginBottom: 16,
}}
/>
{/* Date */}
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: 8,
}}>Дата</div>
<input
type="date" value={date} onChange={e => setDate(e.target.value)}
style={{
width: '100%', padding: '14px 18px', borderRadius: 14, boxSizing: 'border-box' as any,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 15,
outline: 'none', fontFamily: 'inherit',
}}
/>
</div>
{/* All day toggle */}
<button onClick={() => setAllDay(v => !v)} style={{
width: '100%', padding: '13px 18px', borderRadius: 14,
background: allDay ? `${selectedCal.color}10` : 'rgba(255,255,255,0.025)',
border: `1px solid ${allDay ? selectedCal.color + '25' : 'rgba(255,255,255,0.06)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16, transition: 'all 0.2s ease',
}}>
<span style={{
fontSize: 14, fontWeight: 500,
color: allDay ? selectedCal.color : 'var(--text-secondary)',
}}>Весь день</span>
<div style={{
width: 40, height: 22, borderRadius: 11,
background: allDay ? selectedCal.color : 'rgba(255,255,255,0.1)',
position: 'relative', transition: 'background 0.2s ease',
}}>
<div style={{
width: 18, height: 18, borderRadius: '50%',
background: '#fff', position: 'absolute', top: 2,
left: allDay ? 20 : 2,
transition: 'left 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
</button>
{/* Time pickers */}
{!allDay && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: 8,
}}>Время</div>
<div style={{
display: 'flex', alignItems: 'center',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 14, overflow: 'hidden',
}}>
<input
type="time" value={startTime} onChange={e => setStartTime(e.target.value)}
style={{
flex: 1, padding: '14px 18px', border: 'none',
background: 'transparent', color: 'var(--text-primary)',
fontSize: 16, fontWeight: 600, fontFamily: 'inherit',
outline: 'none', textAlign: 'center',
}}
/>
<div style={{
padding: '0 12px', color: 'var(--text-tertiary)',
fontSize: 13, fontWeight: 500, flexShrink: 0,
borderLeft: '1px solid rgba(255,255,255,0.06)',
borderRight: '1px solid rgba(255,255,255,0.06)',
height: '100%', display: 'flex', alignItems: 'center',
background: 'rgba(255,255,255,0.02)',
}}>до</div>
<input
type="time" value={endTime} onChange={e => setEndTime(e.target.value)}
style={{
flex: 1, padding: '14px 18px', border: 'none',
background: 'transparent', color: 'var(--text-primary)',
fontSize: 16, fontWeight: 600, fontFamily: 'inherit',
outline: 'none', textAlign: 'center',
}}
/>
</div>
</div>
)}
{/* Error */}
{error && (
<div style={{
padding: '10px 14px', borderRadius: 12, marginBottom: 16,
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.12)',
color: '#f87171', fontSize: 13, fontWeight: 500,
}}>{error}</div>
)}
{/* Submit */}
<button onClick={save} disabled={saving} style={{
width: '100%', padding: '15px', borderRadius: 14,
background: saving
? 'rgba(255,255,255,0.04)'
: `linear-gradient(135deg, ${selectedCal.color}45, ${selectedCal.color}30)`,
border: `1px solid ${selectedCal.color}35`,
color: selectedCal.color === '#ec4899' ? '#f9a8d4' : '#c7d2fe',
fontSize: 15, fontWeight: 700,
cursor: saving ? 'default' : 'pointer',
transition: 'all 0.25s ease',
boxShadow: saving ? 'none' : `0 4px 16px ${selectedCal.color}18`,
}}>
{saving ? 'Сохранение...' : 'Создать'}
</button>
</div>
</div>
</div>
)
}
// ————— Event Detail Modal —————
function EventDetailModal({ event, onClose, onDelete, onUpdate }: {
event: CalendarEvent
onClose: () => void
onDelete: (e: CalendarEvent) => Promise<void>
onUpdate: (old: CalendarEvent, updated: Partial<CalendarEvent>) => Promise<void>
}) {
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [editing, setEditing] = useState(false)
const [editTitle, setEditTitle] = useState(event.title)
const [editDate, setEditDate] = useState(
event.start.includes('T') ? event.start.split('T')[0] : event.start
)
const [editStartTime, setEditStartTime] = useState(
event.start.includes('T') ? formatTime(event.start) : '10:00'
)
const [editEndTime, setEditEndTime] = useState(
event.end.includes('T') ? formatTime(event.end) : '11:00'
)
const [editAllDay, setEditAllDay] = useState(event.allDay)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const handleDelete = async () => {
setDeleting(true)
await onDelete(event)
setDeleting(false)
}
const handleSave = async () => {
if (!editTitle.trim()) { setSaveError('Введите название'); return }
setSaving(true); setSaveError('')
try {
await onUpdate(event, {
title: editTitle.trim(),
start: editAllDay ? editDate : `${editDate}T${editStartTime}`,
end: editAllDay ? editDate : `${editDate}T${editEndTime}`,
allDay: editAllDay,
})
} catch (e: any) {
setSaveError(e.message || 'Ошибка сохранения')
setSaving(false)
}
}
return (
<div data-swipe-ignore style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(12px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
<div style={{
background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
width: 440, maxWidth: '90vw', overflow: 'hidden',
boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
}} onClick={e => e.stopPropagation()}>
{/* Colored header */}
<div style={{
background: `linear-gradient(135deg, ${event.color}20, ${event.color}08)`,
borderBottom: `1px solid ${event.color}15`,
padding: '22px 24px 18px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<input
value={editTitle} onChange={e => setEditTitle(e.target.value)}
autoFocus
style={{
width: '100%', padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)',
color: 'var(--text-primary)', fontSize: 18, fontWeight: 700,
outline: 'none', fontFamily: 'inherit',
}}
/>
) : (
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.3 }}>
{event.title}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: event.color, boxShadow: `0 0 8px ${event.color}60` }} />
<span style={{ fontSize: 13, color: event.color, fontWeight: 600 }}>{event.ownerName}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 12, flexShrink: 0 }}>
{!editing && (
<button onClick={() => setEditing(true)} style={{
padding: '8px 14px', borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
color: 'var(--text-secondary)', fontSize: 12, fontWeight: 600,
}}>
Изменить
</button>
)}
<button onClick={onClose} aria-label="Закрыть" style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-secondary)',
}}>
<X size={18} />
</button>
</div>
</div>
</div>
{/* Content */}
<div style={{ padding: '20px 24px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{editing ? (
<>
{/* Date */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>Дата</div>
<input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} style={{
width: '100%', padding: '12px 16px', borderRadius: 12, boxSizing: 'border-box' as any,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 15, outline: 'none', fontFamily: 'inherit',
}} />
</div>
{/* All day toggle */}
<button onClick={() => setEditAllDay(v => !v)} style={{
width: '100%', padding: '12px 16px', borderRadius: 12,
background: editAllDay ? `${event.color}10` : 'rgba(255,255,255,0.025)',
border: `1px solid ${editAllDay ? event.color + '25' : 'rgba(255,255,255,0.06)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: 14, fontWeight: 500, color: editAllDay ? event.color : 'var(--text-secondary)' }}>Весь день</span>
<div style={{
width: 40, height: 22, borderRadius: 11,
background: editAllDay ? event.color : 'rgba(255,255,255,0.1)',
position: 'relative', transition: 'background 0.2s ease',
}}>
<div style={{
width: 18, height: 18, borderRadius: '50%', background: '#fff',
position: 'absolute', top: 2, left: editAllDay ? 20 : 2,
transition: 'left 0.2s ease', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
</button>
{/* Time */}
{!editAllDay && (
<div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>Время</div>
<div style={{
display: 'flex', alignItems: 'center',
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 12, overflow: 'hidden',
}}>
<input type="time" value={editStartTime} onChange={e => setEditStartTime(e.target.value)} style={{
flex: 1, padding: '12px 16px', border: 'none', background: 'transparent',
color: 'var(--text-primary)', fontSize: 16, fontWeight: 600,
fontFamily: 'inherit', outline: 'none', textAlign: 'center',
}} />
<div style={{
padding: '0 10px', color: 'var(--text-tertiary)', fontSize: 12,
borderLeft: '1px solid rgba(255,255,255,0.06)',
borderRight: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(255,255,255,0.02)',
height: '100%', display: 'flex', alignItems: 'center',
}}>до</div>
<input type="time" value={editEndTime} onChange={e => setEditEndTime(e.target.value)} style={{
flex: 1, padding: '12px 16px', border: 'none', background: 'transparent',
color: 'var(--text-primary)', fontSize: 16, fontWeight: 600,
fontFamily: 'inherit', outline: 'none', textAlign: 'center',
}} />
</div>
</div>
)}
{saveError && (
<div style={{ color: '#f87171', fontSize: 13, padding: '8px 12px', borderRadius: 10, background: 'rgba(239,68,68,0.08)' }}>{saveError}</div>
)}
{/* Save / Cancel */}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button onClick={handleSave} disabled={saving} style={{
flex: 1, padding: '13px', borderRadius: 14,
background: `linear-gradient(135deg, ${event.color}45, ${event.color}30)`,
border: `1px solid ${event.color}35`,
color: '#c7d2fe', fontSize: 14, fontWeight: 700,
}}>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
<button onClick={() => { setEditing(false); setEditTitle(event.title) }} style={{
padding: '13px 20px', borderRadius: 14,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-secondary)', fontSize: 14, fontWeight: 600,
}}>
Отмена
</button>
</div>
</>
) : (
<>
{/* View mode */}
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', borderRadius: 14,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
}}>
<CalendarDays size={18} color={event.color} />
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', textTransform: 'capitalize' }}>
{formatFullDate(event.start)}
</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
{event.allDay ? 'Весь день' : `${formatTime(event.start)}${formatTime(event.end)}`}
</div>
</div>
</div>
{event.location && (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', borderRadius: 14,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
}}>
<MapPin size={18} color="#fb923c" />
<div style={{ fontSize: 14, color: 'var(--text-primary)', fontWeight: 500 }}>{event.location}</div>
</div>
)}
{event.description && (
<div style={{
padding: '14px 16px', borderRadius: 14,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<AlignLeft size={14} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Описание</span>
</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{event.description}</div>
</div>
)}
{/* Delete */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: 14, marginTop: 4 }}>
{!confirmDelete ? (
<button onClick={() => setConfirmDelete(true)} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '12px', borderRadius: 14, width: '100%',
background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.15)',
color: '#f87171', fontSize: 13, fontWeight: 600,
}}>
<Trash2 size={15} /> Удалить
</button>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleDelete} disabled={deleting} style={{
flex: 1, padding: '12px', borderRadius: 14,
background: deleting ? 'rgba(239,68,68,0.06)' : 'rgba(239,68,68,0.12)',
border: '1px solid rgba(239,68,68,0.25)',
color: '#f87171', fontSize: 13, fontWeight: 600,
}}>
{deleting ? 'Удаление...' : 'Да, удалить'}
</button>
<button onClick={() => setConfirmDelete(false)} style={{
flex: 1, padding: '12px', borderRadius: 14,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600,
}}>
Отмена
</button>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
)
}
// ————— Day Events Popover —————
function DayEventsModal({ day, month, year, events, onClose, onSelect }: {
day: number; month: number; year: number
events: CalendarEvent[]
onClose: () => void
onSelect: (e: CalendarEvent) => void
}) {
const date = new Date(year, month, day)
const label = date.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })
return (
<div data-swipe-ignore style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
<div style={{
background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(40px)',
border: '1px solid rgba(255,255,255,0.08)', borderRadius: 24,
padding: '24px 28px', width: 400, maxWidth: '95vw',
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text-primary)', textTransform: 'capitalize' }}>{label}</span>
<button onClick={onClose} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{events.map(e => (
<button key={e.id} onClick={() => onSelect(e)} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', borderRadius: 14, width: '100%', textAlign: 'left',
background: `${e.color}0c`, border: `1px solid ${e.color}20`,
transition: 'all 0.2s ease',
}}>
<div style={{ width: 4, borderRadius: 2, background: e.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' }}>{e.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 3, display: 'flex', gap: 8 }}>
<span>{e.allDay ? 'Весь день' : `${formatTime(e.start)}${formatTime(e.end)}`}</span>
<span style={{ color: e.color, fontWeight: 500 }}>{e.ownerName}</span>
</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}
// ————— Main Calendar —————
export default function CalendarTab() {
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 [hiddenOwners, setHiddenOwners] = useState<Set<string>>(new Set())
const [dayPopover, setDayPopover] = useState<{ day: number; events: CalendarEvent[] } | null>(null)
const [showUpcoming, setShowUpcoming] = useState(false)
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])
const deleteEvent = async (event: CalendarEvent) => {
try {
const r = await fetch(`/api/calendar?eventId=${event.id}`, { method: 'DELETE' })
const d = await r.json()
if (d.error) throw new Error(d.error)
setEvents(prev => prev.filter(e => e.id !== event.id))
setSelectedEvent(null)
} catch (e: any) { alert(e.message || 'Ошибка удаления') }
}
const updateEvent = async (event: CalendarEvent, updates: Partial<CalendarEvent>) => {
const startStr = updates.start || event.start
const endStr = updates.end || event.end
const body = {
eventId: event.id,
title: updates.title || event.title,
date: startStr.split('T')[0],
startTime: updates.allDay ? null : formatTime(startStr),
endTime: updates.allDay ? null : formatTime(endStr),
allDay: updates.allDay ?? event.allDay,
}
const r = await fetch('/api/calendar', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json()
if (d.error) throw new Error(d.error)
setEvents(prev => prev.map(e => e.id === event.id ? {
...e,
title: updates.title || e.title,
start: d.event.start || e.start,
end: d.event.end || e.end,
allDay: d.event.allDay ?? e.allDay,
} : e))
setSelectedEvent(null)
}
const calendarOwners = useMemo(() => {
const map = new Map<string, { owner: string; ownerName: string; color: string; count: number }>()
events.forEach(e => {
const existing = map.get(e.owner)
if (existing) existing.count++
else map.set(e.owner, { owner: e.owner, ownerName: e.ownerName, color: e.color, count: 1 })
})
return Array.from(map.values())
}, [events])
const toggleOwner = (owner: string) => {
setHiddenOwners(prev => {
const next = new Set(prev)
if (next.has(owner)) next.delete(owner)
else next.add(owner)
return next
})
}
const filteredEvents = useMemo(() => {
if (hiddenOwners.size === 0) return events
return events.filter(e => !hiddenOwners.has(e.owner))
}, [events, hiddenOwners])
const upcoming = filteredEvents
.filter(e => new Date(e.start) >= new Date())
.slice(0, 6)
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
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 filteredEvents.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()
const handleDayClick = (day: number) => {
const dayEvents = getEventsForDay(day)
if (dayEvents.length === 0) {
setAddDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`)
setShowAddModal(true)
} else if (dayEvents.length === 1) {
setSelectedEvent(dayEvents[0])
} else {
setDayPopover({ day, events: dayEvents })
}
}
return (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '16px 24px 24px', position: 'relative' }}>
{/* Main calendar grid */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={prevMonth} aria-label="Предыдущий месяц" style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--surface-2)', border: '1px solid var(--border-subtle)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronLeft size={16} />
</button>
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', minWidth: 180, textAlign: 'center' }}>
{MONTHS[month]} {year}
</span>
<button onClick={nextMonth} aria-label="Следующий месяц" style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--surface-2)', border: '1px solid var(--border-subtle)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronRight size={16} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{calendarOwners.map(cal => {
const isHidden = hiddenOwners.has(cal.owner)
return (
<button key={cal.owner} onClick={() => toggleOwner(cal.owner)} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 14px', borderRadius: 12,
background: isHidden ? 'rgba(255,255,255,0.02)' : `${cal.color}15`,
border: `1px solid ${isHidden ? 'rgba(255,255,255,0.06)' : cal.color + '30'}`,
color: isHidden ? 'var(--text-tertiary)' : cal.color,
fontSize: 12, fontWeight: 600, transition: 'all 0.25s ease',
opacity: isHidden ? 0.5 : 1,
}}>
{isHidden ? <EyeOff size={13} /> : <Eye size={13} />}
{cal.ownerName}
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 6, background: isHidden ? 'rgba(255,255,255,0.05)' : `${cal.color}20` }}>
{cal.count}
</span>
</button>
)
})}
<button onClick={() => setShowUpcoming(v => !v)} aria-label="Ближайшие события" style={{
width: 44, height: 44, borderRadius: 12,
background: showUpcoming
? 'color-mix(in srgb, var(--accent) 18%, var(--surface-2))'
: 'var(--surface-2)',
border: showUpcoming
? '1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle))'
: '1px solid var(--border-subtle)',
color: showUpcoming ? 'var(--accent)' : 'var(--text-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.25s ease',
}}>
<List size={18} />
</button>
<button onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 18px', borderRadius: 14,
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
border: '1px solid rgba(129,140,248,0.25)',
color: '#a5b4fc', fontSize: 13, fontWeight: 600,
}}>
<Plus size={15} /> Событие
</button>
</div>
</div>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
{WEEKDAYS.map((d, i) => (
<div key={d} style={{
textAlign: 'center', fontSize: 11, fontWeight: 600,
color: i >= 5 ? 'rgba(248,113,113,0.5)' : 'var(--text-secondary)',
textTransform: 'uppercase', padding: '6px 0', letterSpacing: '0.05em',
}}>{d}</div>
))}
</div>
{/* Calendar cells */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 3 }}>
{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
const dayOfWeek = (startOffset + day - 1) % 7
const isWeekend = dayOfWeek >= 5
return (
<div
key={idx}
onClick={() => handleDayClick(day)}
style={{
borderRadius: 12, padding: '4px 5px',
background: isToday ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))' : 'rgba(255,255,255,0.015)',
border: isToday ? '1px solid rgba(129,140,248,0.3)' : '1px solid transparent',
cursor: 'pointer', minHeight: 70,
display: 'flex', flexDirection: 'column', gap: 3,
transition: 'all 0.2s ease',
}}
>
<span style={{
fontSize: 12, fontWeight: isToday ? 700 : 500,
color: isToday ? '#a5b4fc' : isWeekend ? 'rgba(248,113,113,0.6)' : 'var(--text-secondary)',
textAlign: 'right', paddingRight: 3, lineHeight: 1,
}}>{day}</span>
{dayEvents.slice(0, 3).map(e => (
<div
key={e.id}
onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }}
style={{
display: 'flex', alignItems: 'center', gap: 4,
background: `${e.color}18`,
borderLeft: `3px solid ${e.color}`,
borderRadius: '0 6px 6px 0',
padding: '4px 6px',
cursor: 'pointer',
overflow: 'hidden',
}}
>
{!e.allDay && (
<span style={{ fontSize: 9, color: `${e.color}cc`, fontWeight: 600, flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatTime(e.start)}
</span>
)}
<span style={{
fontSize: 11, fontWeight: 600, color: e.color,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{e.title}
</span>
</div>
))}
{dayEvents.length > 3 && (
<div style={{ fontSize: 10, color: 'var(--text-secondary)', textAlign: 'center', fontWeight: 500 }}>
+{dayEvents.length - 3}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Right panel — overlay */}
{showUpcoming && <div style={{ position: 'fixed', inset: 0, zIndex: 90 }} onClick={() => setShowUpcoming(false)} />}
<div style={{
position: 'absolute', top: 16, right: 24, bottom: 24,
width: 240, display: 'flex', flexDirection: 'column', gap: 10,
overflowY: 'auto',
background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(30px)', WebkitBackdropFilter: 'blur(30px)',
borderRadius: 22, padding: '20px 16px',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 16px 48px rgba(0,0,0,0.4)',
transform: showUpcoming ? 'translateX(0)' : 'translateX(calc(100% + 24px))',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: 95, pointerEvents: showUpcoming ? 'auto' : 'none',
}}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Ближайшие</div>
{upcoming.length === 0 && !loading && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: '20px 0', textAlign: 'center' }}>Нет событий</div>
)}
{upcoming.map(e => {
const d = new Date(e.start)
return (
<div key={e.id} onClick={() => setSelectedEvent(e)} style={{
borderRadius: 16, padding: '14px',
background: `${e.color}0c`, border: `1px solid ${e.color}1a`,
cursor: 'pointer', transition: 'all 0.25s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, background: `${e.color}1a`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
</div>
<div>
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
<div style={{ fontSize: 10, color: 'var(--text-secondary)' }}>
{e.allDay ? 'Весь день' : formatTime(e.start)}
</div>
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
<div style={{ fontSize: 10, color: e.color, marginTop: 6, fontWeight: 500 }}>{e.ownerName}</div>
</div>
)
})}
</div>
{/* Modals */}
{selectedEvent && (
<EventDetailModal event={selectedEvent} onClose={() => setSelectedEvent(null)} onDelete={deleteEvent} onUpdate={updateEvent} />
)}
{dayPopover && (
<DayEventsModal
day={dayPopover.day} month={month} year={year} events={dayPopover.events}
onClose={() => setDayPopover(null)}
onSelect={e => { setDayPopover(null); setSelectedEvent(e) }}
/>
)}
{showAddModal && (
<AddEventModal
defaultDate={addDate}
onClose={() => setShowAddModal(false)}
onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }}
/>
)}
</div>
)
}