feat: full calendar with Google Calendar + 7-day weather forecast

This commit is contained in:
Cosmo
2026-04-16 07:39:54 +00:00
parent 0039132aec
commit ae1b75f0fd
5 changed files with 466 additions and 129 deletions

View File

@@ -13,9 +13,11 @@ export default function DashboardPage() {
<p className="text-slate-400 text-sm">Добро пожаловать домой</p> <p className="text-slate-400 text-sm">Добро пожаловать домой</p>
</div> </div>
{/* Top row: weather, calendar, tasks */} {/* Weather - full width */}
<div className="grid grid-cols-3 gap-4">
<WeatherWidget /> <WeatherWidget />
{/* Calendar + Tasks */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<CalendarWidget /> <CalendarWidget />
<TasksWidget /> <TasksWidget />
</div> </div>

View File

@@ -1,6 +1,122 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export async function GET() { let cachedToken: { token: string; expiresAt: number } | null = null;
return NextResponse.json({ events: [] });
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 });
}
} }

View File

@@ -1,7 +1,7 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const WMO_DESCRIPTIONS: Record<number, string> = { const WMO: Record<number, string> = {
0: "Ясно", 1: "Преимущественно ясно", 2: "Переменная облачность", 3: "Пасмурно", 0: "Ясно", 1: "Преимущественно ясно", 2: "Переменная облачность", 3: "Пасмурно",
45: "Туман", 48: "Ледяной туман", 51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось", 45: "Туман", 48: "Ледяной туман", 51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
61: "Лёгкий дождь", 63: "Дождь", 65: "Сильный дождь", 61: "Лёгкий дождь", 63: "Дождь", 65: "Сильный дождь",
@@ -9,33 +9,48 @@ const WMO_DESCRIPTIONS: Record<number, string> = {
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень", 95: "Гроза", 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() { export async function GET() {
try { try {
// Open-Meteo: координаты Санкт-Петербурга
const lat = 59.9343; const lat = 59.9343;
const lon = 30.3351; const lon = 30.3351;
const meteoRes = await fetch( const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code&wind_speed_unit=kmh`, `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
`&current=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) } { signal: AbortSignal.timeout(6000) }
); );
if (!meteoRes.ok) throw new Error("Open-Meteo error"); if (!res.ok) throw new Error("Open-Meteo error");
const meteoData = await meteoRes.json(); const d = await res.json();
const c = meteoData.current; const c = d.current;
const normalized = { return NextResponse.json({
current_condition: [{ current: {
temp_C: Math.round(c.temperature_2m).toString(), temp: Math.round(c.temperature_2m),
FeelsLikeC: Math.round(c.apparent_temperature).toString(), feelsLike: Math.round(c.apparent_temperature),
humidity: Math.round(c.relative_humidity_2m).toString(), humidity: Math.round(c.relative_humidity_2m),
windspeedKmph: Math.round(c.wind_speed_10m).toString(), windKmh: Math.round(c.wind_speed_10m),
weatherDesc: [{ value: WMO_DESCRIPTIONS[c.weather_code] ?? "Неизвестно" }], desc: WMO[c.weather_code] ?? "Неизвестно",
}], icon: WMO_ICON[c.weather_code] ?? "🌡️",
source: "open-meteo", },
}; forecast: d.daily.time.map((date: string, i: number) => ({
date,
return NextResponse.json(normalized); 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) { } catch (e) {
return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 }); return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 });
} }

View File

@@ -1,71 +1,244 @@
"use client"; "use client";
import { Calendar, RefreshCw } from "lucide-react"; import { useState, useEffect, useCallback } from "react";
import { useEffect, useState } from "react"; import { ChevronLeft, ChevronRight, Plus, X, Clock, ExternalLink } from "lucide-react";
interface CalEvent { interface CalEvent {
id: string; id: string; title: string; start: string; end?: string; allDay: boolean; htmlLink?: string;
title: string; }
time?: 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() { export function CalendarWidget() {
const [events, setEvents] = useState<CalEvent[]>([]); const today = new Date();
const [loading, setLoading] = useState(true); 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 () => { const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`;
setLoading(true);
const fetchMonth = useCallback(async () => {
try { try {
const res = await fetch("/api/calendar"); const res = await fetch(`/api/calendar?month=${monthStr}`);
const data = await res.json(); const data = await res.json();
setEvents(data.events ?? []); if (data.error && data.events?.length === 0) {
} catch { setCalError(data.error);
setEvents([]); } else {
} finally { setCalError(null);
setLoading(false);
} }
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 ( return (
<div className="glass-card p-5"> <div className="glass-card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <button onClick={prevMonth} className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors">
<Calendar className="w-4 h-4 text-violet-400" /> <ChevronLeft className="w-4 h-4" />
<span className="text-sm font-medium text-slate-300">Календарь</span> </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>
<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"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{today}</span> <label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
<button onClick={fetchEvents} className="text-slate-500 hover:text-slate-300 transition-colors"> <input type="checkbox" className="accent-indigo-500" checked={newEvent.allDay}
<RefreshCw className="w-3.5 h-3.5" /> 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> </button>
</div> </div>
</div> </div>
)}
{loading ? ( {loadingDay ? (
<div className="space-y-2 animate-pulse"> <div className="space-y-1.5 animate-pulse">
{[1,2].map(i => <div key={i} className="h-10 bg-slate-700/50 rounded" />)} {[1,2].map(i => <div key={i} className="h-8 bg-slate-700/50 rounded-lg" />)}
</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>
</div> </div>
) : dayEvents.length === 0 ? (
<div className="text-xs text-slate-600 text-center py-2">Нет событий</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-1.5 max-h-40 overflow-y-auto">
{events.map((ev) => ( {dayEvents.map(ev => (
<div key={ev.id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/40"> <div key={ev.id} className="flex items-start gap-2 p-2 rounded-lg bg-slate-800/40 group">
<div className="w-1 h-8 rounded-full bg-violet-500" /> <div className="w-1 h-full min-h-[32px] rounded-full bg-violet-500 mt-0.5 flex-shrink-0" />
<div> <div className="flex-1 min-w-0">
<div className="text-sm text-white">{ev.title}</div> <div className="text-xs font-medium text-white truncate">{ev.title}</div>
{ev.time && <div className="text-xs text-slate-400">{ev.time}</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>
</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> </div>
)}
</div>
); );
} }

View File

@@ -1,83 +1,114 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Cloud, Thermometer, Wind, Droplets, RefreshCw } from "lucide-react"; import { RefreshCw, Wind, Droplets, Thermometer } from "lucide-react";
interface WeatherData { interface CurrentWeather {
current_condition: Array<{ temp: number; feelsLike: number; humidity: number; windKmh: number; desc: string; icon: string;
temp_C: string;
FeelsLikeC: string;
humidity: string;
windspeedKmph: string;
weatherDesc: Array<{ value: 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() { export function WeatherWidget() {
const [data, setData] = useState<WeatherData | null>(null); const [data, setData] = useState<WeatherData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [selected, setSelected] = useState(0);
const fetchWeather = async () => { const fetchData = async () => {
setLoading(true); setLoading(true); setError(false);
setError(false);
try { try {
const res = await fetch("/api/weather"); const res = await fetch("/api/weather");
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const json = await res.json(); setData(await res.json());
setData(json); } catch { setError(true); }
} catch { finally { setLoading(false); }
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 ( return (
<div className="glass-card p-5"> <div className="glass-card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="text-sm font-medium text-slate-300">Погода · Санкт-Петербург</span>
<Cloud className="w-4 h-4 text-blue-400" /> <button onClick={fetchData} className="text-slate-500 hover:text-slate-300 transition-colors">
<span className="text-sm font-medium text-slate-300">Погода</span> <RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
</div>
<button onClick={fetchWeather} className="text-slate-500 hover:text-slate-300 transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
{loading ? ( {error ? (
<div className="space-y-2 animate-pulse"> <div className="text-slate-500 text-sm text-center py-4">Нет данных</div>
<div className="h-8 bg-slate-700/50 rounded w-24" /> ) : loading ? (
<div className="h-4 bg-slate-700/50 rounded w-32" /> <div className="space-y-3 animate-pulse">
<div className="h-4 bg-slate-700/50 rounded w-40" /> <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> </div>
) : error ? ( ) : c && day ? (
<div className="text-slate-500 text-sm">Не удалось получить данные</div> <>
) : current ? ( <div className="flex items-center gap-4">
<div className="space-y-3"> <span className="text-5xl">{selected === 0 ? c.icon : day.icon}</span>
<div className="flex items-baseline gap-2"> <div>
<span className="text-4xl font-bold text-white">{current.temp_C}°</span> <div className="flex items-baseline gap-1">
<span className="text-slate-400 text-sm">Санкт-Петербург</span> <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>
<div className="text-slate-300 text-sm">{current.weatherDesc?.[0]?.value}</div> <div className="text-slate-300 text-sm">{selected === 0 ? c.desc : day.desc}</div>
<div className="grid grid-cols-3 gap-2 pt-1"> <div className="text-xs text-slate-500">{selected === 0 ? "Сейчас" : formatDate(day.date)}</div>
<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> </div>
<div className="flex items-center gap-1 text-xs text-slate-400"> {selected === 0 && (
<Droplets className="w-3 h-3 text-blue-400" /> <div className="ml-auto flex flex-col gap-1 text-xs text-slate-400">
<span>{current.humidity}%</span> <span className="flex items-center gap-1"><Thermometer className="w-3 h-3 text-orange-400"/>Ощущ. {c.feelsLike}°</span>
</div> <span className="flex items-center gap-1"><Droplets className="w-3 h-3 text-blue-400"/>{c.humidity}%</span>
<div className="flex items-center gap-1 text-xs text-slate-400"> <span className="flex items-center gap-1"><Wind className="w-3 h-3 text-teal-400"/>{c.windKmh} км/ч</span>
<Wind className="w-3 h-3 text-teal-400" />
<span>{current.windspeedKmph} км/ч</span>
</div> </div>
)}
</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> </div>
</>
) : null} ) : null}
</div> </div>
); );