redesign: modern dark dashboard with gradients and animations
- New color system: deep #0a0a0f bg with indigo/violet/emerald accents - Sidebar redesign: gradient DH badge, improved active state, cleaner typography - Card system: .card class with colored top-border accents, hover glow - WeatherWidget: large temperature display, gradient bg, better 7-day grid - CalendarWidget: gradient today highlight, violet accents, improved events panel - TasksWidget: priority dots, hover states, max-height scroll - System page: circular SVG gauges with glow, accent cards, better bars - Bookmarks page: hover lift effect, colored category headers - Claude widgets: gradient badge headers, accent borders - DashboardHeader: live clock, gradient greeting text - ServicesGrid: pulsing online indicator (animate-ping), card lift on hover - globals.css: Inter font, custom scrollbar, card/glass-card, gradient-text helper - tailwind.config.ts: dash colors, gradient BG images, glow/float animations
This commit is contained in:
@@ -35,11 +35,8 @@ export function CalendarWidget() {
|
||||
try {
|
||||
const res = await fetch(`/api/calendar?month=${monthStr}`);
|
||||
const data = await res.json();
|
||||
if (data.error && data.events?.length === 0) {
|
||||
setCalError(data.error);
|
||||
} else {
|
||||
setCalError(null);
|
||||
}
|
||||
if (data.error && data.events?.length === 0) setCalError(data.error);
|
||||
else setCalError(null);
|
||||
setMonthEvents(data.events ?? []);
|
||||
} catch { setMonthEvents([]); }
|
||||
}, [monthStr]);
|
||||
@@ -47,8 +44,7 @@ export function CalendarWidget() {
|
||||
useEffect(() => { fetchMonth(); }, [fetchMonth]);
|
||||
|
||||
const fetchDay = async (dateStr: string) => {
|
||||
setLoadingDay(true);
|
||||
setDayEvents([]);
|
||||
setLoadingDay(true); setDayEvents([]);
|
||||
try {
|
||||
const res = await fetch(`/api/calendar?date=${dateStr}`);
|
||||
const data = await res.json();
|
||||
@@ -73,9 +69,7 @@ export function CalendarWidget() {
|
||||
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 datesWithEvents = new Set(monthEvents.map(e => (e.start || "").split("T")[0]));
|
||||
|
||||
const createEvent = async () => {
|
||||
if (!newEvent.title.trim() || !selectedDate) return;
|
||||
@@ -84,13 +78,7 @@ export function CalendarWidget() {
|
||||
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,
|
||||
}),
|
||||
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);
|
||||
@@ -98,7 +86,7 @@ export function CalendarWidget() {
|
||||
setNewEvent({ title: "", startTime: "09:00", endTime: "10:00", allDay: false });
|
||||
fetchDay(selectedDate);
|
||||
fetchMonth();
|
||||
} catch (e: any) { setCreateError(e.message || "Ошибка создания"); }
|
||||
} catch (e: unknown) { setCreateError(e instanceof Error ? e.message : "Ошибка создания"); }
|
||||
finally { setCreating(false); }
|
||||
};
|
||||
|
||||
@@ -106,30 +94,31 @@ export function CalendarWidget() {
|
||||
|
||||
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="card card-accent-emerald 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 flex flex-col gap-4">
|
||||
<div className="card card-accent-violet p-5 flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-white/5 text-slate-500 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">
|
||||
<span className="text-sm font-semibold gradient-text">{MONTHS[month]} {year}</span>
|
||||
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-white transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday labels */}
|
||||
<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>
|
||||
<div key={d} className="text-center text-[10px] font-semibold text-slate-600 py-1">{d}</div>
|
||||
))}
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={`empty-${i}`} />;
|
||||
@@ -141,39 +130,44 @@ export function CalendarWidget() {
|
||||
<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"
|
||||
className={`relative flex flex-col items-center justify-center aspect-square rounded-xl text-sm transition-all ${
|
||||
isSelected
|
||||
? "text-white font-semibold"
|
||||
: isToday
|
||||
? "text-violet-300 font-semibold"
|
||||
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
style={isSelected ? { background: "linear-gradient(135deg, #6366f1, #8b5cf6)" } :
|
||||
isToday ? { background: "rgba(139,92,246,0.15)", boxShadow: "inset 0 0 0 1px rgba(139,92,246,0.4)" } : {}}
|
||||
>
|
||||
{day}
|
||||
{hasEvents && (
|
||||
<span className={`absolute bottom-1 w-1 h-1 rounded-full ${isSelected ? "bg-white" : "bg-indigo-400"}`} />
|
||||
<span className={`absolute bottom-1 w-1 h-1 rounded-full ${isSelected ? "bg-white" : "bg-violet-400"}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Day events panel */}
|
||||
{selectedDate && (
|
||||
<div className="border-t border-slate-700/50 pt-3 space-y-2">
|
||||
<div className="border-t border-white/5 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"
|
||||
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-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">
|
||||
<div className="bg-white/3 rounded-xl p-3 space-y-2 border border-white/5">
|
||||
<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"
|
||||
className="w-full bg-white/5 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-600 outline-none focus:ring-1 focus:ring-violet-500 border border-white/5"
|
||||
placeholder="Название события"
|
||||
value={newEvent.title}
|
||||
onChange={e => setNewEvent(n => ({ ...n, title: e.target.value }))}
|
||||
@@ -181,7 +175,7 @@ export function CalendarWidget() {
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<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}
|
||||
<input type="checkbox" className="accent-violet-500" checked={newEvent.allDay}
|
||||
onChange={e => setNewEvent(n => ({ ...n, allDay: e.target.checked }))} />
|
||||
Весь день
|
||||
</label>
|
||||
@@ -189,19 +183,20 @@ export function CalendarWidget() {
|
||||
{!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>
|
||||
className="flex-1 bg-white/5 rounded-lg px-2 py-1.5 text-xs text-white outline-none focus:ring-1 focus:ring-violet-500" />
|
||||
<span className="text-slate-600 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" />
|
||||
className="flex-1 bg-white/5 rounded-lg px-2 py-1.5 text-xs text-white outline-none focus:ring-1 focus:ring-violet-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">
|
||||
className="flex-1 text-white text-xs py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{ background: "linear-gradient(135deg, #6366f1, #8b5cf6)" }}>
|
||||
{creating ? "Создаю..." : "Создать"}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="p-2 rounded-lg hover:bg-slate-700/50 text-slate-400">
|
||||
<button onClick={() => setShowCreate(false)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-white">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -210,18 +205,19 @@ export function CalendarWidget() {
|
||||
|
||||
{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" />)}
|
||||
{[1,2].map(i => <div key={i} className="h-8 bg-white/5 rounded-lg" />)}
|
||||
</div>
|
||||
) : dayEvents.length === 0 ? (
|
||||
<div className="text-xs text-slate-600 text-center py-2">Нет событий</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 key={ev.id} className="flex items-start gap-2 p-2.5 rounded-xl bg-white/3 border border-white/5 group">
|
||||
<div className="w-0.5 h-full min-h-[32px] rounded-full flex-shrink-0"
|
||||
style={{ background: "linear-gradient(to bottom, #8b5cf6, #6366f1)" }} />
|
||||
<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">
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{ev.allDay ? "Весь день" : formatTime(ev.start)}
|
||||
{ev.end && !ev.allDay && ` — ${formatTime(ev.end)}`}
|
||||
@@ -229,7 +225,7 @@ export function CalendarWidget() {
|
||||
</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">
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-white transition-all">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -11,42 +11,38 @@ export function ClaudeApiWidget() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-purple-500/20 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-purple-400">A</span>
|
||||
<div className="card card-accent-violet p-5">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
|
||||
style={{ background: "linear-gradient(135deg, #8b5cf6, #6366f1)" }}>
|
||||
A
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Claude API</div>
|
||||
<div className="text-[10px] text-slate-500">Usage & Cost</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-300">Claude API Usage & Cost</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Обновить"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||
{refreshing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Стоимость", value: "—" },
|
||||
{ label: "Запросы", value: "—" },
|
||||
{ label: "Токены", value: "—" },
|
||||
{ label: "Стоимость", value: "—", color: "text-violet-400" },
|
||||
{ label: "Запросы", value: "—", color: "text-white" },
|
||||
{ label: "Токены", value: "—", color: "text-white" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-slate-800/40 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-white">{item.value}</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{item.label}</div>
|
||||
<div key={item.label} className="rounded-xl p-3 text-center border border-white/5" style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||
<div className={`text-xl font-bold ${item.color}`}>{item.value}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-yellow-500" />
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-violet-500/50" />
|
||||
<span>Автоматическое получение данных недоступно</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,42 +11,38 @@ export function ClaudeUsageWidget() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-orange-500/20 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-orange-400">C</span>
|
||||
<div className="card card-accent-amber p-5">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
|
||||
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}>
|
||||
C
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Claude Subscription</div>
|
||||
<div className="text-[10px] text-slate-500">Usage</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-300">Claude Subscription Usage</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Обновить"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||
{refreshing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Использовано", value: "—" },
|
||||
{ label: "Лимит", value: "—" },
|
||||
{ label: "Сброс", value: "—" },
|
||||
{ label: "Использовано", value: "—", color: "text-amber-400" },
|
||||
{ label: "Лимит", value: "—", color: "text-white" },
|
||||
{ label: "Сброс", value: "—", color: "text-white" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-slate-800/40 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-white">{item.value}</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{item.label}</div>
|
||||
<div key={item.label} className="rounded-xl p-3 text-center border border-white/5" style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||
<div className={`text-xl font-bold ${item.color}`}>{item.value}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-yellow-500" />
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500/50" />
|
||||
<span>Автоматическое получение данных недоступно</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
50
src/components/widgets/DashboardHeader.tsx
Normal file
50
src/components/widgets/DashboardHeader.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
const hour = time.getHours();
|
||||
const greeting =
|
||||
hour < 6 ? "Доброй ночи" :
|
||||
hour < 12 ? "Доброе утро" :
|
||||
hour < 17 ? "Добрый день" :
|
||||
hour < 22 ? "Добрый вечер" :
|
||||
"Доброй ночи";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-2 pb-2">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{greeting},{" "}
|
||||
<span className="gradient-text">Daniil</span>{" "}
|
||||
<span>👋</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
{time.toLocaleDateString("ru-RU", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
{" · "}
|
||||
{time.toLocaleTimeString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-3 py-1.5 rounded-full text-xs font-medium text-indigo-300 border border-indigo-500/20 bg-indigo-500/10">
|
||||
digital-home.site
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,13 @@ interface Service {
|
||||
interface ServiceCategory {
|
||||
label: string;
|
||||
services: Service[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const SERVICE_CATEGORIES: ServiceCategory[] = [
|
||||
{
|
||||
label: "Productivity",
|
||||
accent: "text-emerald-400",
|
||||
services: [
|
||||
{ name: "Pulse", url: "https://pulse.digital-home.site", icon: "https://pulse.digital-home.site/favicon.svg", desc: "Привычки и задачи", emoji: "💓" },
|
||||
{ name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" },
|
||||
@@ -25,6 +27,7 @@ const SERVICE_CATEGORIES: ServiceCategory[] = [
|
||||
},
|
||||
{
|
||||
label: "Storage",
|
||||
accent: "text-blue-400",
|
||||
services: [
|
||||
{ name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" },
|
||||
{ name: "Immich", url: "https://photo.digital-home.site", desc: "Фото галерея", emoji: "📸" },
|
||||
@@ -32,6 +35,7 @@ const SERVICE_CATEGORIES: ServiceCategory[] = [
|
||||
},
|
||||
{
|
||||
label: "Tools",
|
||||
accent: "text-cyan-400",
|
||||
services: [
|
||||
{ name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" },
|
||||
{ name: "IT-Tools", url: "https://tools.digital-home.site", desc: "Утилиты разработчика", emoji: "🛠️" },
|
||||
@@ -42,6 +46,7 @@ const SERVICE_CATEGORIES: ServiceCategory[] = [
|
||||
},
|
||||
{
|
||||
label: "AI Subscribe",
|
||||
accent: "text-violet-400",
|
||||
services: [
|
||||
{ name: "OpenAI Usage", url: "https://chatgpt.com/codex/cloud/settings/usage", desc: "Лимиты OpenAI", emoji: "🤖" },
|
||||
{ name: "Claude Usage", url: "https://claude.ai/settings/usage", desc: "Лимиты Claude", emoji: "✨" },
|
||||
@@ -49,6 +54,7 @@ const SERVICE_CATEGORIES: ServiceCategory[] = [
|
||||
},
|
||||
{
|
||||
label: "AI API",
|
||||
accent: "text-amber-400",
|
||||
services: [
|
||||
{ name: "Moonshot AI", url: "https://platform.moonshot.ai/console/account", desc: "Баланс и API ключи", emoji: "🌙" },
|
||||
{ name: "OpenAI API", url: "https://platform.openai.com/settings/organization/usage", desc: "OpenAI API статистика", emoji: "📈" },
|
||||
@@ -79,34 +85,37 @@ function ServiceCard({ service }: { service: Service }) {
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-card p-4 flex items-center gap-3 hover:scale-[1.02] transition-transform group"
|
||||
className="card p-4 flex items-center gap-3 hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-800/80 border border-slate-700/50 flex items-center justify-center shrink-0 text-xl">
|
||||
<div className="w-10 h-10 rounded-xl border border-white/5 flex items-center justify-center shrink-0 text-xl"
|
||||
style={{ background: "rgba(255,255,255,0.05)" }}>
|
||||
{service.icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={service.icon} alt={service.name} className="w-6 h-6 object-contain" onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).parentElement!.textContent = service.emoji ?? "🔗";
|
||||
const parent = (e.target as HTMLImageElement).parentElement;
|
||||
if (parent) parent.textContent = service.emoji ?? "🔗";
|
||||
}} />
|
||||
) : (
|
||||
<span>{service.emoji ?? "🔗"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-white truncate">{service.name}</span>
|
||||
<ExternalLink className="w-3 h-3 text-slate-600 shrink-0 group-hover:text-slate-400 transition-colors" />
|
||||
<ExternalLink className="w-3 h-3 text-slate-700 shrink-0 group-hover:text-slate-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">{service.desc}</p>
|
||||
<p className="text-xs text-slate-600 truncate mt-0.5">{service.desc}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
status === "online" ? "status-online" :
|
||||
status === "offline" ? "status-offline" :
|
||||
"status-checking animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
<div className="shrink-0 relative">
|
||||
{status === "online" && (
|
||||
<span className="absolute inset-0 rounded-full bg-emerald-400 opacity-40 animate-ping" />
|
||||
)}
|
||||
<span className={`relative block w-2 h-2 rounded-full ${
|
||||
status === "online" ? "bg-emerald-500" :
|
||||
status === "offline" ? "bg-red-500" :
|
||||
"bg-slate-600 animate-pulse"
|
||||
}`} />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
@@ -115,10 +124,13 @@ function ServiceCard({ service }: { service: Service }) {
|
||||
export function ServicesGrid() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">Сервисы</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-white">Сервисы</h2>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
</div>
|
||||
{SERVICE_CATEGORIES.map((cat) => (
|
||||
<div key={cat.label}>
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">{cat.label}</h3>
|
||||
<h3 className={`text-xs font-bold uppercase tracking-widest mb-3 ${cat.accent}`}>{cat.label}</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{cat.services.map((svc) => (
|
||||
<ServiceCard key={svc.url} service={svc} />
|
||||
|
||||
@@ -29,45 +29,48 @@ export function TasksWidget() {
|
||||
|
||||
useEffect(() => { fetchTasks(); }, []);
|
||||
|
||||
const priorityColor: Record<string, string> = {
|
||||
high: "text-red-400",
|
||||
medium: "text-yellow-400",
|
||||
low: "text-green-400",
|
||||
const priorityDot: Record<string, string> = {
|
||||
high: "bg-red-500",
|
||||
medium: "bg-amber-500",
|
||||
low: "bg-emerald-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="card card-accent-emerald p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Задачи сегодня</span>
|
||||
<span className="text-sm font-semibold text-white">Задачи</span>
|
||||
</div>
|
||||
<button onClick={fetchTasks} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<button onClick={fetchTasks} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
{[1,2,3].map(i => <div key={i} className="h-8 bg-slate-700/50 rounded" />)}
|
||||
{[1,2,3].map(i => <div key={i} className="h-9 bg-white/5 rounded-xl" />)}
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-600">
|
||||
<CheckSquare className="w-8 h-8 mb-2 opacity-30" />
|
||||
<span className="text-sm">Задач нет</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-start gap-2 p-2 rounded-lg hover:bg-slate-800/40 transition-colors">
|
||||
<div key={task.id} className="flex items-start gap-2.5 p-2.5 rounded-xl hover:bg-white/3 transition-colors group">
|
||||
{task.done ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-slate-500 shrink-0 mt-0.5" />
|
||||
<Circle className="w-4 h-4 text-slate-600 shrink-0 mt-0.5 group-hover:text-slate-400 transition-colors" />
|
||||
)}
|
||||
<span className={"text-sm " + (task.done ? "text-slate-500 line-through" : "text-slate-200")}>
|
||||
<span className={`text-sm flex-1 ${task.done ? "text-slate-600 line-through" : "text-slate-200"}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.priority && !task.done && (
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${priorityDot[task.priority] ?? "bg-slate-600"}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Wind, Droplets, Thermometer } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface CurrentWeather {
|
||||
temp: number; feelsLike: number; humidity: number; windKmh: number; desc: string; icon: string;
|
||||
@@ -14,7 +14,6 @@ interface WeatherData {
|
||||
}
|
||||
|
||||
const DAY_NAMES = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"];
|
||||
const MONTH_NAMES = ["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"];
|
||||
|
||||
export function WeatherWidget() {
|
||||
const [data, setData] = useState<WeatherData | null>(null);
|
||||
@@ -36,80 +35,98 @@ export function WeatherWidget() {
|
||||
|
||||
const c = data?.current;
|
||||
const day = data?.forecast?.[selected];
|
||||
const formatDate = (dateStr: string) => {
|
||||
|
||||
const formatDay = (dateStr: string, idx: number) => {
|
||||
if (idx === 0) return "Сег";
|
||||
const d = new Date(dateStr + "T12:00:00");
|
||||
return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${MONTH_NAMES[d.getMonth()]}`;
|
||||
return DAY_NAMES[d.getDay()];
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="card card-accent-blue relative overflow-hidden">
|
||||
{/* Decorative background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/20 via-transparent to-violet-900/10 pointer-events-none" />
|
||||
|
||||
{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 className="relative p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Погода</span>
|
||||
<span className="text-xs text-slate-500">· Санкт-Петербург</span>
|
||||
</div>
|
||||
<button onClick={fetchData} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
) : 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>
|
||||
|
||||
{error ? (
|
||||
<div className="text-slate-500 text-sm text-center py-8">Нет данных о погоде</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="h-20 w-20 bg-white/5 rounded-2xl" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-12 w-28 bg-white/5 rounded-lg" />
|
||||
<div className="h-4 w-40 bg-white/5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">{[...Array(7)].map((_,i) => <div key={i} className="flex-1 h-24 bg-white/5 rounded-xl" />)}</div>
|
||||
</div>
|
||||
) : c && day ? (
|
||||
<>
|
||||
{/* Current weather */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span className="text-7xl font-extralight text-white leading-none">
|
||||
{selected === 0 ? c.temp : day.maxTemp}°
|
||||
</span>
|
||||
<span className="text-4xl">{selected === 0 ? c.icon : day.icon}</span>
|
||||
</div>
|
||||
<p className="text-slate-300 mt-2">{selected === 0 ? c.desc : day.desc}</p>
|
||||
{selected === 0 && (
|
||||
<p className="text-slate-500 text-xs mt-0.5">Ощущается как {c.feelsLike}°</p>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{selected === 0 && (
|
||||
<div className="text-right space-y-2 mt-2">
|
||||
<div className="flex items-center gap-2 justify-end text-sm">
|
||||
<span className="text-slate-500 text-xs">Влажность</span>
|
||||
<span className="text-blue-400 font-medium">💧 {c.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end text-sm">
|
||||
<span className="text-slate-500 text-xs">Ветер</span>
|
||||
<span className="text-cyan-400 font-medium">💨 {c.windKmh} км/ч</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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 (
|
||||
{/* 7-day forecast */}
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{data!.forecast.map((f, i) => (
|
||||
<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 ${
|
||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all ${
|
||||
selected === i
|
||||
? "bg-indigo-600/60 ring-1 ring-indigo-500"
|
||||
: "bg-slate-800/40 hover:bg-slate-700/50"
|
||||
? "bg-white/10 ring-1 ring-white/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<span className="text-[10px] font-medium text-slate-400">{formatDay(f.date, i)}</span>
|
||||
<span className="text-xl">{f.icon}</span>
|
||||
<span className="text-xs font-semibold text-white">{f.maxTemp}°</span>
|
||||
<span className="text-[10px] text-slate-600">{f.minTemp}°</span>
|
||||
{f.precipProb > 20 && (
|
||||
<span className="text-[10px] text-blue-400">{f.precipProb}%</span>
|
||||
<span className="text-[9px] text-blue-400 font-medium">{f.precipProb}%</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user