From 690db4c6cf6a3a26efb52c321a068003a8789f9c Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 22 Apr 2026 19:56:38 +0000 Subject: [PATCH] feat: event editing, light/dark theme, device animations, 7-day forecast --- app/api/calendar/route.ts | 48 ++++++ app/api/weather/route.ts | 2 +- app/globals.css | 63 +++++++ app/page.tsx | 45 ++++- components/CalendarTab.tsx | 344 +++++++++++++++++++++++++------------ components/DeviceCard.tsx | 16 +- components/TopBar.tsx | 2 +- 7 files changed, 394 insertions(+), 126 deletions(-) diff --git a/app/api/calendar/route.ts b/app/api/calendar/route.ts index e47593a..a43d06f 100644 --- a/app/api/calendar/route.ts +++ b/app/api/calendar/route.ts @@ -155,6 +155,54 @@ export async function POST(req: Request) { } } + +export async function PUT(req: Request) { + const body = await req.json() + const { eventId, title, date, startTime, endTime, allDay, calendarId } = body + + if (!eventId) { + return NextResponse.json({ error: 'eventId is required' }, { status: 400 }) + } + + const auth = getAuth(false) + if (!auth) return NextResponse.json({ error: 'not_configured' }, { status: 500 }) + + const targetCalendarId = calendarId || process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com' + const calendarClient = google.calendar({ version: 'v3', auth: auth as any }) + + let start: any, end: any + if (allDay) { + start = { date } + end = { date } + } else { + start = { dateTime: , timeZone: 'Europe/Moscow' } + end = { dateTime: , timeZone: 'Europe/Moscow' } + } + + try { + const res = await calendarClient.events.patch({ + calendarId: targetCalendarId, + eventId, + 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, + } + }) + } catch (err: any) { + return NextResponse.json({ error: err.message || 'Failed to update event' }, { status: 500 }) + } +} export async function DELETE(req: Request) { const { searchParams } = new URL(req.url) const eventId = searchParams.get('eventId') diff --git a/app/api/weather/route.ts b/app/api/weather/route.ts index 8afbbcc..829a0f3 100644 --- a/app/api/weather/route.ts +++ b/app/api/weather/route.ts @@ -51,7 +51,7 @@ export async function GET(req: Request) { current: "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m", daily: "weather_code,temperature_2m_max,temperature_2m_min", timezone: "Europe/Moscow", - forecast_days: "3", + forecast_days: "7", }); const res = await fetch(url, { diff --git a/app/globals.css b/app/globals.css index 65474a5..8d3c21a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -156,3 +156,66 @@ button:focus-visible { .animate-slide-up { animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; } + +/* ————— Light theme ————— */ +.light { + --bg: #f5f5fa; + --bg-secondary: #eeeef4; + --sidebar-bg: rgba(255, 255, 255, 0.8); + --card-bg: rgba(255, 255, 255, 0.7); + --card-bg-hover: rgba(255, 255, 255, 0.85); + --card-border: rgba(0, 0, 0, 0.06); + --card-border-hover: rgba(0, 0, 0, 0.1); + --text-primary: rgba(0, 0, 0, 0.88); + --text-secondary: rgba(0, 0, 0, 0.45); + --text-tertiary: rgba(0, 0, 0, 0.2); + --accent: #6366f1; + --accent-secondary: #0891b2; + --accent-glow: rgba(99, 102, 241, 0.12); + --glass: rgba(255, 255, 255, 0.5); + --glass-border: rgba(0, 0, 0, 0.06); + --on-color: #6366f1; + --off-color: rgba(0, 0, 0, 0.12); +} + +.light .bg-ambient::before { + background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%); +} +.light .bg-ambient::after { + background: radial-gradient(circle, rgba(139, 92, 246, 0.04) 0%, transparent 70%); +} + +.light ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); +} +.light ::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.2); +} + +/* ————— Device animations ————— */ +@keyframes fan-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes light-pulse { + 0%, 100% { filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.3)); } + 50% { filter: drop-shadow(0 0 12px rgba(251, 191, 36, 0.6)); } +} + +@keyframes device-breathe { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +.fan-spinning { + animation: fan-spin 2s linear infinite; +} + +.light-on-pulse { + animation: light-pulse 3s ease-in-out infinite; +} + +.device-active-breathe { + animation: device-breathe 3s ease-in-out infinite; +} diff --git a/app/page.tsx b/app/page.tsx index c0ac9db..7c32234 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -391,7 +391,7 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S } // ————— Settings Tab ————— -function SettingsTab({ city, onCityChange, onLogout }: { city: string; onCityChange: (id: string) => void; onLogout: () => void }) { +function SettingsTab({ city, onCityChange, onLogout, theme, onThemeChange }: { city: string; onCityChange: (id: string) => void; onLogout: () => void; theme: string; onThemeChange: (t: string) => void }) { const [showPinChange, setShowPinChange] = useState(false) const [oldPin, setOldPin] = useState('') const [newPin, setNewPin] = useState('') @@ -431,7 +431,7 @@ function SettingsTab({ city, onCityChange, onLogout }: { city: string; onCityCha

Настройки

{/* City selector */} -
+
Город @@ -457,8 +457,35 @@ function SettingsTab({ city, onCityChange, onLogout }: { city: string; onCityCha
+ {/* Theme */} +
+
+
+ {theme === 'dark' ? '🌙' : '☀️'} + Тема +
+
+ {[ + { id: 'dark', label: 'Тёмная' }, + { id: 'light', label: 'Светлая' }, + ].map(t => ( + + ))} +
+
+
+ {/* PIN change */} -
+
@@ -551,8 +578,18 @@ function HomePageInner() { return 'spb' }) const [screensaverActive, setScreensaverActive] = useState(false) + const [theme, setTheme] = useState(() => { + if (typeof window !== 'undefined') return localStorage.getItem('tablet-theme') || 'dark' + return 'dark' + }) const idleTimer = useRef | null>(null) + // Theme + useEffect(() => { + document.documentElement.className = theme + localStorage.setItem('tablet-theme', theme) + }, [theme]) + // Auth check useEffect(() => { fetch('/api/auth') @@ -690,7 +727,7 @@ function HomePageInner() { {tab === 'settings' && ( - + )} diff --git a/components/CalendarTab.tsx b/components/CalendarTab.tsx index 26487b6..5ebc452 100644 --- a/components/CalendarTab.tsx +++ b/components/CalendarTab.tsx @@ -255,13 +255,28 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; } // ————— Event Detail Modal ————— -function EventDetailModal({ event, onClose, onDelete }: { +function EventDetailModal({ event, onClose, onDelete, onUpdate }: { event: CalendarEvent onClose: () => void onDelete: (e: CalendarEvent) => Promise + onUpdate: (old: CalendarEvent, updated: Partial) => Promise }) { 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) @@ -269,151 +284,254 @@ function EventDetailModal({ event, onClose, onDelete }: { setDeleting(false) } - const startDate = new Date(event.start) - const endDate = new Date(event.end) + 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 (
e.stopPropagation()}> - {/* Colored header band */} + {/* Colored header */}
-
- {event.title} -
-
-
- {event.ownerName} + {editing ? ( + 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', + }} + /> + ) : ( +
+ {event.title} +
+ )} +
+
+ {event.ownerName}
- +
+ {!editing && ( + + )} + +
{/* Content */} -
+
- {/* Date & Time */} -
-
- -
-
-
- {formatFullDate(event.start)} + {editing ? ( + <> + {/* Date */} +
+
Дата
+ setEditDate(e.target.value)} style={{ + width: '100%', padding: '12px 16px', borderRadius: 12, + 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', + }} />
-
- {event.allDay - ? 'Весь день' - : `${formatTime(event.start)} — ${formatTime(event.end)}` - } -
-
-
- {/* Location */} - {event.location && ( -
-
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', }}> - -
-
{event.location}
-
- )} - - {/* Description */} - {event.description && ( -
-
- - Описание -
-
- {event.description} -
-
- )} - - {/* Delete */} -
- {!confirmDelete ? ( - - ) : ( -
- + + {/* Time */} + {!editAllDay && ( +
+
Время
+
+ 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', + }} /> +
до
+ 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', + }} /> +
+
+ )} + + {saveError && ( +
{saveError}
+ )} + + {/* Save / Cancel */} +
+ -
- )} -
+ + ) : ( + <> + {/* View mode */} +
+ +
+
+ {formatFullDate(event.start)} +
+
+ {event.allDay ? 'Весь день' : `${formatTime(event.start)} — ${formatTime(event.end)}`} +
+
+
+ + {event.location && ( +
+ +
{event.location}
+
+ )} + + {event.description && ( +
+
+ + Описание +
+
{event.description}
+
+ )} + + {/* Delete */} +
+ {!confirmDelete ? ( + + ) : ( +
+ + +
+ )} +
+ + )}
) } + // ————— Day Events Popover ————— function DayEventsModal({ day, month, year, events, onClose, onSelect }: { day: number; month: number; year: number @@ -734,7 +852,7 @@ export default function CalendarTab() { {/* Modals */} {selectedEvent && ( - setSelectedEvent(null)} onDelete={deleteEvent} /> + setSelectedEvent(null)} onDelete={deleteEvent} onUpdate={updateEvent} /> )} {dayPopover && ( diff --git a/components/DeviceCard.tsx b/components/DeviceCard.tsx index d0c3d3a..45dd458 100644 --- a/components/DeviceCard.tsx +++ b/components/DeviceCard.tsx @@ -111,13 +111,15 @@ export default function DeviceCard({ {/* Top: icon + toggle */}
-
+
{getDeviceIcon(id, isOn)}
diff --git a/components/TopBar.tsx b/components/TopBar.tsx index e9183de..0603307 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -255,7 +255,7 @@ export default function TopBar({ weather, sensors, haConnected }: TopBarProps) { fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 14, }}> - Прогноз на ближайшие дни + Прогноз на неделю
{weather.forecast.map(day => {