From ae1b75f0fd6218b31d73020e1ef5449005683d51 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 16 Apr 2026 07:39:54 +0000 Subject: [PATCH] feat: full calendar with Google Calendar + 7-day weather forecast --- src/app/(dashboard)/page.tsx | 8 +- src/app/api/calendar/route.ts | 122 +++++++++- src/app/api/weather/route.ts | 53 +++-- src/components/widgets/CalendarWidget.tsx | 269 ++++++++++++++++++---- src/components/widgets/WeatherWidget.tsx | 143 +++++++----- 5 files changed, 466 insertions(+), 129 deletions(-) diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 2c26906..19d4483 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -13,9 +13,11 @@ export default function DashboardPage() {

Добро пожаловать домой

- {/* Top row: weather, calendar, tasks */} -
- + {/* Weather - full width */} + + + {/* Calendar + Tasks */} +
diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index 219e625..5982771 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -1,6 +1,122 @@ export const dynamic = "force-dynamic"; -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; -export async function GET() { - return NextResponse.json({ events: [] }); +let cachedToken: { token: string; expiresAt: number } | null = null; + +const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ""; +const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ""; +const GOOGLE_REFRESH_TOKEN = process.env.GOOGLE_REFRESH_TOKEN || ""; +const CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || "primary"; + +async function getAccessToken(): Promise { + if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) { + return cachedToken.token; + } + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + refresh_token: GOOGLE_REFRESH_TOKEN, + grant_type: "refresh_token", + }), + }); + const data = await res.json(); + if (!data.access_token) throw new Error("Failed to get access token: " + JSON.stringify(data)); + cachedToken = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 60) * 1000 }; + return cachedToken.token; +} + +export async function GET(req: NextRequest) { + if (!GOOGLE_CLIENT_ID || !GOOGLE_REFRESH_TOKEN) { + return NextResponse.json({ events: [], error: "Google Calendar not configured" }); + } + + const date = req.nextUrl.searchParams.get("date"); + const month = req.nextUrl.searchParams.get("month"); + + try { + const token = await getAccessToken(); + + let timeMin: string, timeMax: string; + if (date) { + timeMin = `${date}T00:00:00Z`; + timeMax = `${date}T23:59:59Z`; + } else if (month) { + timeMin = `${month}-01T00:00:00Z`; + const lastDay = new Date(parseInt(month.split("-")[0]), parseInt(month.split("-")[1]), 0).getDate(); + timeMax = `${month}-${String(lastDay).padStart(2,"0")}T23:59:59Z`; + } else { + const today = new Date().toISOString().split("T")[0]; + timeMin = `${today}T00:00:00Z`; + timeMax = `${today}T23:59:59Z`; + } + + const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(CALENDAR_ID)}/events?timeMin=${encodeURIComponent(timeMin)}&timeMax=${encodeURIComponent(timeMax)}&singleEvents=true&orderBy=startTime&maxResults=50`; + + const calRes = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(8000), + }); + + const calData = await calRes.json(); + + if (!calRes.ok) { + throw new Error(calData.error?.message || "Calendar API error"); + } + + const events = (calData.items || []).map((item: any) => ({ + id: item.id, + title: item.summary || "(без названия)", + start: item.start?.dateTime || item.start?.date, + end: item.end?.dateTime || item.end?.date, + allDay: !item.start?.dateTime, + color: item.colorId ? `#${item.colorId}` : null, + htmlLink: item.htmlLink, + })); + + return NextResponse.json({ events }); + } catch (e) { + return NextResponse.json({ events: [], error: String(e) }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + if (!GOOGLE_CLIENT_ID || !GOOGLE_REFRESH_TOKEN) { + return NextResponse.json({ error: "Google Calendar not configured" }, { status: 400 }); + } + + try { + const body = await req.json(); + const token = await getAccessToken(); + + const event = { + summary: body.title, + description: body.description || "", + start: body.allDay + ? { date: body.date } + : { dateTime: `${body.date}T${body.startTime || "09:00"}:00`, timeZone: "Europe/Moscow" }, + end: body.allDay + ? { date: body.date } + : { dateTime: `${body.date}T${body.endTime || "10:00"}:00`, timeZone: "Europe/Moscow" }, + }; + + const createRes = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(CALENDAR_ID)}/events`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(8000), + } + ); + + const created = await createRes.json(); + if (!createRes.ok) throw new Error(created.error?.message || "Failed to create event"); + + return NextResponse.json({ success: true, event: created }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } } diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts index 03e9d04..c5c5459 100644 --- a/src/app/api/weather/route.ts +++ b/src/app/api/weather/route.ts @@ -1,7 +1,7 @@ export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; -const WMO_DESCRIPTIONS: Record = { +const WMO: Record = { 0: "Ясно", 1: "Преимущественно ясно", 2: "Переменная облачность", 3: "Пасмурно", 45: "Туман", 48: "Ледяной туман", 51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось", 61: "Лёгкий дождь", 63: "Дождь", 65: "Сильный дождь", @@ -9,33 +9,48 @@ const WMO_DESCRIPTIONS: Record = { 80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень", 95: "Гроза", }; +const WMO_ICON: Record = { + 0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️", + 45: "🌫️", 48: "🌫️", 51: "🌦️", 53: "🌦️", 55: "🌧️", + 61: "🌧️", 63: "🌧️", 65: "🌧️", 71: "🌨️", 73: "❄️", 75: "❄️", + 80: "🌦️", 81: "🌧️", 82: "⛈️", 95: "⛈️", +}; + export async function GET() { try { - // Open-Meteo: координаты Санкт-Петербурга const lat = 59.9343; const lon = 30.3351; - const meteoRes = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code&wind_speed_unit=kmh`, + const res = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + + `¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code` + + `&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max` + + `&wind_speed_unit=kmh&timezone=Europe/Moscow&forecast_days=7`, { signal: AbortSignal.timeout(6000) } ); - if (!meteoRes.ok) throw new Error("Open-Meteo error"); - const meteoData = await meteoRes.json(); - const c = meteoData.current; + if (!res.ok) throw new Error("Open-Meteo error"); + const d = await res.json(); + const c = d.current; - const normalized = { - current_condition: [{ - temp_C: Math.round(c.temperature_2m).toString(), - FeelsLikeC: Math.round(c.apparent_temperature).toString(), - humidity: Math.round(c.relative_humidity_2m).toString(), - windspeedKmph: Math.round(c.wind_speed_10m).toString(), - weatherDesc: [{ value: WMO_DESCRIPTIONS[c.weather_code] ?? "Неизвестно" }], - }], - source: "open-meteo", - }; - - return NextResponse.json(normalized); + return NextResponse.json({ + current: { + temp: Math.round(c.temperature_2m), + feelsLike: Math.round(c.apparent_temperature), + humidity: Math.round(c.relative_humidity_2m), + windKmh: Math.round(c.wind_speed_10m), + desc: WMO[c.weather_code] ?? "Неизвестно", + icon: WMO_ICON[c.weather_code] ?? "🌡️", + }, + forecast: d.daily.time.map((date: string, i: number) => ({ + date, + maxTemp: Math.round(d.daily.temperature_2m_max[i]), + minTemp: Math.round(d.daily.temperature_2m_min[i]), + desc: WMO[d.daily.weather_code[i]] ?? "Неизвестно", + icon: WMO_ICON[d.daily.weather_code[i]] ?? "🌡️", + precipProb: d.daily.precipitation_probability_max[i], + })), + }); } catch (e) { return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 }); } diff --git a/src/components/widgets/CalendarWidget.tsx b/src/components/widgets/CalendarWidget.tsx index 7890bf1..e0e5380 100644 --- a/src/components/widgets/CalendarWidget.tsx +++ b/src/components/widgets/CalendarWidget.tsx @@ -1,69 +1,242 @@ "use client"; -import { Calendar, RefreshCw } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { ChevronLeft, ChevronRight, Plus, X, Clock, ExternalLink } from "lucide-react"; interface CalEvent { - id: string; - title: string; - time?: string; + id: string; title: string; start: string; end?: string; allDay: boolean; htmlLink?: string; +} + +const WEEK_DAYS = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"]; +const MONTHS = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"]; + +function formatTime(dateStr: string) { + if (!dateStr.includes("T")) return "Весь день"; + const d = new Date(dateStr); + return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" }); } export function CalendarWidget() { - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); + const today = new Date(); + const [year, setYear] = useState(today.getFullYear()); + const [month, setMonth] = useState(today.getMonth()); + const [selectedDate, setSelectedDate] = useState(null); + const [dayEvents, setDayEvents] = useState([]); + const [monthEvents, setMonthEvents] = useState([]); + const [loadingDay, setLoadingDay] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [newEvent, setNewEvent] = useState({ title: "", startTime: "09:00", endTime: "10:00", allDay: false }); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + const [calError, setCalError] = useState(null); - const fetchEvents = async () => { - setLoading(true); + const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`; + + const fetchMonth = useCallback(async () => { try { - const res = await fetch("/api/calendar"); + const res = await fetch(`/api/calendar?month=${monthStr}`); const data = await res.json(); - setEvents(data.events ?? []); - } catch { - setEvents([]); - } finally { - setLoading(false); - } + if (data.error && data.events?.length === 0) { + setCalError(data.error); + } else { + setCalError(null); + } + setMonthEvents(data.events ?? []); + } catch { setMonthEvents([]); } + }, [monthStr]); + + useEffect(() => { fetchMonth(); }, [fetchMonth]); + + const fetchDay = async (dateStr: string) => { + setLoadingDay(true); + setDayEvents([]); + try { + const res = await fetch(`/api/calendar?date=${dateStr}`); + const data = await res.json(); + setDayEvents(data.events ?? []); + } catch { setDayEvents([]); } + finally { setLoadingDay(false); } }; - useEffect(() => { fetchEvents(); }, []); + const handleDayClick = (day: number) => { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + setSelectedDate(dateStr); + fetchDay(dateStr); + setShowCreate(false); + setCreateError(""); + }; - const today = new Date().toLocaleDateString("ru-RU", { day: "numeric", month: "long" }); + const prevMonth = () => { if (month === 0) { setYear(y => y-1); setMonth(11); } else setMonth(m => m-1); }; + const nextMonth = () => { if (month === 11) { setYear(y => y+1); setMonth(0); } else setMonth(m => m+1); }; + + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const startOffset = firstDay === 0 ? 6 : firstDay - 1; + const cells = Array(startOffset).fill(null).concat(Array.from({ length: daysInMonth }, (_, i) => i + 1)); + + const datesWithEvents = new Set( + monthEvents.map(e => (e.start || "").split("T")[0]) + ); + + const createEvent = async () => { + if (!newEvent.title.trim() || !selectedDate) return; + setCreating(true); setCreateError(""); + try { + const res = await fetch("/api/calendar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: newEvent.title, + date: selectedDate, + startTime: newEvent.startTime, + endTime: newEvent.endTime, + allDay: newEvent.allDay, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setShowCreate(false); + setNewEvent({ title: "", startTime: "09:00", endTime: "10:00", allDay: false }); + fetchDay(selectedDate); + fetchMonth(); + } catch (e: any) { setCreateError(e.message || "Ошибка создания"); } + finally { setCreating(false); } + }; + + const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,"0")}-${String(today.getDate()).padStart(2,"0")}`; + + if (calError === "Google Calendar not configured") { + return ( +
+
📅
+
Google Calendar
+
Не настроен. Добавь переменные окружения в Coolify.
+
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN
+
+ ); + } return ( -
-
-
- - Календарь -
-
- {today} - -
+
+
+ + {MONTHS[month]} {year} +
- {loading ? ( -
- {[1,2].map(i =>
)} -
- ) : events.length === 0 ? ( -
- - Нет событий -
- ) : ( -
- {events.map((ev) => ( -
-
-
-
{ev.title}
- {ev.time &&
{ev.time}
} +
+ {WEEK_DAYS.map(d => ( +
{d}
+ ))} + {cells.map((day, i) => { + if (!day) return
; + const dateStr = `${year}-${String(month+1).padStart(2,"0")}-${String(day).padStart(2,"0")}`; + const isToday = dateStr === todayStr; + const isSelected = dateStr === selectedDate; + const hasEvents = datesWithEvents.has(dateStr); + return ( + + ); + })} +
+ + {selectedDate && ( +
+
+ + {new Date(selectedDate + "T12:00:00").toLocaleDateString("ru-RU", { day: "numeric", month: "long" })} + + +
+ + {showCreate && ( +
+ setNewEvent(n => ({ ...n, title: e.target.value }))} + onKeyDown={e => e.key === "Enter" && createEvent()} + /> +
+ +
+ {!newEvent.allDay && ( +
+ setNewEvent(n => ({ ...n, startTime: e.target.value }))} + className="flex-1 bg-slate-700/50 rounded-lg px-2 py-1.5 text-xs text-white outline-none focus:ring-1 focus:ring-indigo-500" /> + + setNewEvent(n => ({ ...n, endTime: e.target.value }))} + className="flex-1 bg-slate-700/50 rounded-lg px-2 py-1.5 text-xs text-white outline-none focus:ring-1 focus:ring-indigo-500" /> +
+ )} + {createError &&
{createError}
} +
+ +
- ))} + )} + + {loadingDay ? ( +
+ {[1,2].map(i =>
)} +
+ ) : dayEvents.length === 0 ? ( +
Нет событий
+ ) : ( +
+ {dayEvents.map(ev => ( +
+
+
+
{ev.title}
+
+ + {ev.allDay ? "Весь день" : formatTime(ev.start)} + {ev.end && !ev.allDay && ` — ${formatTime(ev.end)}`} +
+
+ {ev.htmlLink && ( + + + + )} +
+ ))} +
+ )}
)}
diff --git a/src/components/widgets/WeatherWidget.tsx b/src/components/widgets/WeatherWidget.tsx index eb646ce..7f9c538 100644 --- a/src/components/widgets/WeatherWidget.tsx +++ b/src/components/widgets/WeatherWidget.tsx @@ -1,83 +1,114 @@ "use client"; import { useEffect, useState } from "react"; -import { Cloud, Thermometer, Wind, Droplets, RefreshCw } from "lucide-react"; +import { RefreshCw, Wind, Droplets, Thermometer } from "lucide-react"; -interface WeatherData { - current_condition: Array<{ - temp_C: string; - FeelsLikeC: string; - humidity: string; - windspeedKmph: string; - weatherDesc: Array<{ value: string }>; - }>; +interface CurrentWeather { + temp: number; feelsLike: number; humidity: number; windKmh: number; desc: string; icon: string; } +interface DayForecast { + date: string; maxTemp: number; minTemp: number; desc: string; icon: string; precipProb: number; +} +interface WeatherData { + current: CurrentWeather; + forecast: DayForecast[]; +} + +const DAY_NAMES = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]; +const MONTH_NAMES = ["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"]; export function WeatherWidget() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const [selected, setSelected] = useState(0); - const fetchWeather = async () => { - setLoading(true); - setError(false); + const fetchData = async () => { + setLoading(true); setError(false); try { const res = await fetch("/api/weather"); if (!res.ok) throw new Error(); - const json = await res.json(); - setData(json); - } catch { - setError(true); - } finally { - setLoading(false); - } + setData(await res.json()); + } catch { setError(true); } + finally { setLoading(false); } }; - useEffect(() => { fetchWeather(); }, []); + useEffect(() => { fetchData(); }, []); - const current = data?.current_condition?.[0]; + const c = data?.current; + const day = data?.forecast?.[selected]; + const formatDate = (dateStr: string) => { + const d = new Date(dateStr + "T12:00:00"); + return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${MONTH_NAMES[d.getMonth()]}`; + }; return ( -
-
-
- - Погода -
-
- {loading ? ( -
-
-
-
+ {error ? ( +
Нет данных
+ ) : loading ? ( +
+
+
+
{[...Array(7)].map((_,i)=>
)}
- ) : error ? ( -
Не удалось получить данные
- ) : current ? ( -
-
- {current.temp_C}° - Санкт-Петербург + ) : c && day ? ( + <> +
+ {selected === 0 ? c.icon : day.icon} +
+
+ + {selected === 0 ? c.temp : day.maxTemp}° + + {selected !== 0 && ( + {day.minTemp}° + )} +
+
{selected === 0 ? c.desc : day.desc}
+
{selected === 0 ? "Сейчас" : formatDate(day.date)}
+
+ {selected === 0 && ( +
+ Ощущ. {c.feelsLike}° + {c.humidity}% + {c.windKmh} км/ч +
+ )}
-
{current.weatherDesc?.[0]?.value}
-
-
- - Ощущ. {current.FeelsLikeC}° -
-
- - {current.humidity}% -
-
- - {current.windspeedKmph} км/ч -
+ +
+ {data.forecast.map((f, i) => { + const d = new Date(f.date + "T12:00:00"); + const isToday = i === 0; + return ( + + ); + })}
-
+ ) : null}
);