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}
+
+ )}
+
-
+
+ {!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.description && (
+
+
+
{event.description}
+
+ )}
+
+ {/* Delete */}
+
+ {!confirmDelete ? (
+
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,
+ }}>
+ Удалить
+
+ ) : (
+
+
+ {deleting ? 'Удаление...' : 'Да, удалить'}
+
+ 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,
+ }}>
+ Отмена
+
+
+ )}
+
+ >
+ )}
)
}
+
// ————— 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 => {