feat: full calendar with Google Calendar + 7-day weather forecast
This commit is contained in:
@@ -13,9 +13,11 @@ export default function DashboardPage() {
|
||||
<p className="text-slate-400 text-sm">Добро пожаловать домой</p>
|
||||
</div>
|
||||
|
||||
{/* Top row: weather, calendar, tasks */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Weather - full width */}
|
||||
<WeatherWidget />
|
||||
|
||||
{/* Calendar + Tasks */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<CalendarWidget />
|
||||
<TasksWidget />
|
||||
</div>
|
||||
|
||||
@@ -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<string> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const WMO_DESCRIPTIONS: Record<number, string> = {
|
||||
const WMO: Record<number, string> = {
|
||||
0: "Ясно", 1: "Преимущественно ясно", 2: "Переменная облачность", 3: "Пасмурно",
|
||||
45: "Туман", 48: "Ледяной туман", 51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
|
||||
61: "Лёгкий дождь", 63: "Дождь", 65: "Сильный дождь",
|
||||
@@ -9,33 +9,48 @@ const WMO_DESCRIPTIONS: Record<number, string> = {
|
||||
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень", 95: "Гроза",
|
||||
};
|
||||
|
||||
const WMO_ICON: Record<number, string> = {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1,71 +1,244 @@
|
||||
"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<CalEvent[]>([]);
|
||||
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<string | null>(null);
|
||||
const [dayEvents, setDayEvents] = useState<CalEvent[]>([]);
|
||||
const [monthEvents, setMonthEvents] = useState<CalEvent[]>([]);
|
||||
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<string | null>(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 (
|
||||
<div className="glass-card p-5 flex flex-col gap-4 items-center justify-center min-h-[200px]">
|
||||
<div className="text-4xl">📅</div>
|
||||
<div className="text-sm font-medium text-white">Google Calendar</div>
|
||||
<div className="text-xs text-slate-400 text-center">Не настроен. Добавь переменные окружения в Coolify.</div>
|
||||
<div className="text-[10px] text-slate-600 text-center">GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-violet-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Календарь</span>
|
||||
<div className="glass-card p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={prevMonth} className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-white">{MONTHS[month]} {year}</span>
|
||||
<button onClick={nextMonth} className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{WEEK_DAYS.map(d => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-slate-500 py-1">{d}</div>
|
||||
))}
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={`empty-${i}`} />;
|
||||
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 (
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`relative flex flex-col items-center justify-center aspect-square rounded-lg text-sm transition-all ${
|
||||
isSelected ? "bg-indigo-600 text-white font-semibold" :
|
||||
isToday ? "bg-slate-700 text-indigo-300 font-semibold ring-1 ring-indigo-500" :
|
||||
"text-slate-300 hover:bg-slate-700/50"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{hasEvents && (
|
||||
<span className={`absolute bottom-1 w-1 h-1 rounded-full ${isSelected ? "bg-white" : "bg-indigo-400"}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<div className="border-t border-slate-700/50 pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-400">
|
||||
{new Date(selectedDate + "T12:00:00").toLocaleDateString("ru-RU", { day: "numeric", month: "long" })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowCreate(s => !s)}
|
||||
className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="bg-slate-800/60 rounded-xl p-3 space-y-2">
|
||||
<input
|
||||
className="w-full bg-slate-700/50 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="Название события"
|
||||
value={newEvent.title}
|
||||
onChange={e => setNewEvent(n => ({ ...n, title: e.target.value }))}
|
||||
onKeyDown={e => e.key === "Enter" && createEvent()}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{today}</span>
|
||||
<button onClick={fetchEvents} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
|
||||
<input type="checkbox" className="accent-indigo-500" checked={newEvent.allDay}
|
||||
onChange={e => setNewEvent(n => ({ ...n, allDay: e.target.checked }))} />
|
||||
Весь день
|
||||
</label>
|
||||
</div>
|
||||
{!newEvent.allDay && (
|
||||
<div className="flex gap-2">
|
||||
<input type="time" value={newEvent.startTime} onChange={e => 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" />
|
||||
<span className="text-slate-500 self-center">—</span>
|
||||
<input type="time" value={newEvent.endTime} onChange={e => 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" />
|
||||
</div>
|
||||
)}
|
||||
{createError && <div className="text-xs text-red-400">{createError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={createEvent} disabled={creating || !newEvent.title.trim()}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-xs py-2 rounded-lg transition-colors">
|
||||
{creating ? "Создаю..." : "Создать"}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="p-2 rounded-lg hover:bg-slate-700/50 text-slate-400">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
{[1,2].map(i => <div key={i} className="h-10 bg-slate-700/50 rounded" />)}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-slate-500">
|
||||
<Calendar className="w-8 h-8 mb-2 opacity-30" />
|
||||
<span className="text-sm">Нет событий</span>
|
||||
{loadingDay ? (
|
||||
<div className="space-y-1.5 animate-pulse">
|
||||
{[1,2].map(i => <div key={i} className="h-8 bg-slate-700/50 rounded-lg" />)}
|
||||
</div>
|
||||
) : dayEvents.length === 0 ? (
|
||||
<div className="text-xs text-slate-600 text-center py-2">Нет событий</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{events.map((ev) => (
|
||||
<div key={ev.id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/40">
|
||||
<div className="w-1 h-8 rounded-full bg-violet-500" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{ev.title}</div>
|
||||
{ev.time && <div className="text-xs text-slate-400">{ev.time}</div>}
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{dayEvents.map(ev => (
|
||||
<div key={ev.id} className="flex items-start gap-2 p-2 rounded-lg bg-slate-800/40 group">
|
||||
<div className="w-1 h-full min-h-[32px] rounded-full bg-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-white truncate">{ev.title}</div>
|
||||
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{ev.allDay ? "Весь день" : formatTime(ev.start)}
|
||||
{ev.end && !ev.allDay && ` — ${formatTime(ev.end)}`}
|
||||
</div>
|
||||
</div>
|
||||
{ev.htmlLink && (
|
||||
<a href={ev.htmlLink} target="_blank" rel="noopener noreferrer"
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-white transition-all">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<WeatherData | null>(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 (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Погода</span>
|
||||
</div>
|
||||
<button onClick={fetchWeather} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<div className="glass-card p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-300">Погода · Санкт-Петербург</span>
|
||||
<button onClick={fetchData} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-8 bg-slate-700/50 rounded w-24" />
|
||||
<div className="h-4 bg-slate-700/50 rounded w-32" />
|
||||
<div className="h-4 bg-slate-700/50 rounded w-40" />
|
||||
{error ? (
|
||||
<div className="text-slate-500 text-sm text-center py-4">Нет данных</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="h-12 bg-slate-700/50 rounded w-32" />
|
||||
<div className="h-4 bg-slate-700/50 rounded w-48" />
|
||||
<div className="flex gap-2">{[...Array(7)].map((_,i)=><div key={i} className="h-20 bg-slate-700/50 rounded flex-1"/>)}</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-slate-500 text-sm">Не удалось получить данные</div>
|
||||
) : current ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-white">{current.temp_C}°</span>
|
||||
<span className="text-slate-400 text-sm">Санкт-Петербург</span>
|
||||
) : c && day ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-5xl">{selected === 0 ? c.icon : day.icon}</span>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-white">
|
||||
{selected === 0 ? c.temp : day.maxTemp}°
|
||||
</span>
|
||||
{selected !== 0 && (
|
||||
<span className="text-slate-400 text-lg">{day.minTemp}°</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-slate-300 text-sm">{current.weatherDesc?.[0]?.value}</div>
|
||||
<div className="grid grid-cols-3 gap-2 pt-1">
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Thermometer className="w-3 h-3 text-orange-400" />
|
||||
<span>Ощущ. {current.FeelsLikeC}°</span>
|
||||
<div className="text-slate-300 text-sm">{selected === 0 ? c.desc : day.desc}</div>
|
||||
<div className="text-xs text-slate-500">{selected === 0 ? "Сейчас" : formatDate(day.date)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Droplets className="w-3 h-3 text-blue-400" />
|
||||
<span>{current.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Wind className="w-3 h-3 text-teal-400" />
|
||||
<span>{current.windspeedKmph} км/ч</span>
|
||||
{selected === 0 && (
|
||||
<div className="ml-auto flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1"><Thermometer className="w-3 h-3 text-orange-400"/>Ощущ. {c.feelsLike}°</span>
|
||||
<span className="flex items-center gap-1"><Droplets className="w-3 h-3 text-blue-400"/>{c.humidity}%</span>
|
||||
<span className="flex items-center gap-1"><Wind className="w-3 h-3 text-teal-400"/>{c.windKmh} км/ч</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{data.forecast.map((f, i) => {
|
||||
const d = new Date(f.date + "T12:00:00");
|
||||
const isToday = i === 0;
|
||||
return (
|
||||
<button
|
||||
key={f.date}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`flex-1 min-w-[52px] flex flex-col items-center gap-1 p-2 rounded-xl transition-all text-center ${
|
||||
selected === i
|
||||
? "bg-indigo-600/60 ring-1 ring-indigo-500"
|
||||
: "bg-slate-800/40 hover:bg-slate-700/50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-slate-400">{isToday ? "Сег" : DAY_NAMES[d.getDay()]}</span>
|
||||
<span className="text-lg">{f.icon}</span>
|
||||
<span className="text-xs font-medium text-white">{f.maxTemp}°</span>
|
||||
<span className="text-xs text-slate-500">{f.minTemp}°</span>
|
||||
{f.precipProb > 20 && (
|
||||
<span className="text-[10px] text-blue-400">{f.precipProb}%</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user