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:
Cosmo
2026-04-16 07:47:58 +00:00
parent ae1b75f0fd
commit f16318ff8e
14 changed files with 493 additions and 387 deletions

View File

@@ -3,6 +3,7 @@ export default function BookmarksPage() {
{ {
label: "Productivity", label: "Productivity",
emoji: "💼", emoji: "💼",
accent: "text-emerald-400",
links: [ links: [
{ name: "Pulse", url: "https://pulse.digital-home.site", desc: "Привычки и задачи", emoji: "💓" }, { name: "Pulse", url: "https://pulse.digital-home.site", desc: "Привычки и задачи", emoji: "💓" },
{ name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" }, { name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" },
@@ -11,6 +12,7 @@ export default function BookmarksPage() {
{ {
label: "Storage", label: "Storage",
emoji: "💾", emoji: "💾",
accent: "text-blue-400",
links: [ links: [
{ name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" }, { name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" },
{ name: "Immich", url: "https://photo.digital-home.site", desc: "Фото галерея", emoji: "📸" }, { name: "Immich", url: "https://photo.digital-home.site", desc: "Фото галерея", emoji: "📸" },
@@ -19,6 +21,7 @@ export default function BookmarksPage() {
{ {
label: "Tools", label: "Tools",
emoji: "🔧", emoji: "🔧",
accent: "text-cyan-400",
links: [ links: [
{ name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" }, { name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" },
{ name: "IT-Tools", url: "https://tools.digital-home.site", desc: "Утилиты разработчика", emoji: "🛠️" }, { name: "IT-Tools", url: "https://tools.digital-home.site", desc: "Утилиты разработчика", emoji: "🛠️" },
@@ -30,6 +33,7 @@ export default function BookmarksPage() {
{ {
label: "AI Subscribe", label: "AI Subscribe",
emoji: "🤖", emoji: "🤖",
accent: "text-violet-400",
links: [ links: [
{ name: "OpenAI Usage", url: "https://chatgpt.com/codex/cloud/settings/usage", desc: "Лимиты OpenAI", emoji: "🤖" }, { 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: "✨" }, { name: "Claude Usage", url: "https://claude.ai/settings/usage", desc: "Лимиты Claude", emoji: "✨" },
@@ -42,6 +46,7 @@ export default function BookmarksPage() {
{ {
label: "Dev & References", label: "Dev & References",
emoji: "💻", emoji: "💻",
accent: "text-amber-400",
links: [ links: [
{ name: "GitHub", url: "https://github.com/", desc: "Репозитории", emoji: "🐱" }, { name: "GitHub", url: "https://github.com/", desc: "Репозитории", emoji: "🐱" },
{ name: "Go Playground", url: "https://go.dev/play/", desc: "Тест Go кода", emoji: "🐹" }, { name: "Go Playground", url: "https://go.dev/play/", desc: "Тест Go кода", emoji: "🐹" },
@@ -62,18 +67,20 @@ export default function BookmarksPage() {
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-7xl mx-auto">
<div> <div className="pt-2 pb-2">
<h1 className="text-2xl font-bold text-white">Bookmarks</h1> <h1 className="text-3xl font-bold text-white">Bookmarks</h1>
<p className="text-slate-400 text-sm">Все ссылки в одном месте</p> <p className="text-slate-500 text-sm mt-1">Все ссылки в одном месте</p>
</div> </div>
{categories.map((cat) => ( {categories.map((cat) => (
<div key={cat.label}> <div key={cat.label}>
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3"> <div className="flex items-center gap-3 mb-3">
<span>{cat.emoji}</span> <h3 className={`text-xs font-bold uppercase tracking-widest ${cat.accent}`}>
{cat.label} {cat.emoji} {cat.label}
</h3> </h3>
<div className="flex-1 h-px bg-white/5" />
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{cat.links.map((link) => ( {cat.links.map((link) => (
<a <a
@@ -81,16 +88,16 @@ export default function BookmarksPage() {
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="glass-card p-4 hover:scale-[1.02] transition-transform group" className="card p-4 hover:-translate-y-1 transition-all duration-200 group"
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-2">
<span className="text-lg">{link.emoji}</span> <span className="text-xl">{link.emoji}</span>
<div className="text-sm font-medium text-white group-hover:text-indigo-300 transition-colors truncate"> <div className="text-sm font-medium text-white truncate group-hover:text-indigo-300 transition-colors">
{link.name} {link.name}
</div> </div>
</div> </div>
{link.desc && <div className="text-xs text-slate-500 mt-1 truncate">{link.desc}</div>} {link.desc && <div className="text-xs text-slate-600 truncate">{link.desc}</div>}
<div className="text-xs text-slate-700 mt-2 truncate text-right group-hover:text-slate-500 transition-colors"> <div className="text-[10px] text-slate-700 mt-2 truncate text-right group-hover:text-slate-500 transition-colors">
{new URL(link.url).hostname} {new URL(link.url).hostname}
</div> </div>
</a> </a>

View File

@@ -13,9 +13,9 @@ export default async function DashboardLayout({
} }
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen" style={{ background: "#0a0a0f" }}>
<Sidebar userName={session.user?.name} /> <Sidebar userName={session.user?.name} />
<main className="flex-1 ml-64 p-6 min-h-screen"> <main className="flex-1 ml-64 p-6 min-h-screen" style={{ background: "#0a0a0f" }}>
{children} {children}
</main> </main>
</div> </div>

View File

@@ -4,26 +4,28 @@ import { TasksWidget } from "@/components/widgets/TasksWidget";
import { CalendarWidget } from "@/components/widgets/CalendarWidget"; import { CalendarWidget } from "@/components/widgets/CalendarWidget";
import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget"; import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget"; import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget";
import { DashboardHeader } from "@/components/widgets/DashboardHeader";
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-7xl mx-auto">
<div> <DashboardHeader />
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-slate-400 text-sm">Добро пожаловать домой</p>
</div>
{/* Weather - full width */} {/* Weather - full width */}
<WeatherWidget /> <WeatherWidget />
{/* Calendar + Tasks */} {/* Calendar + Tasks */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<CalendarWidget /> <div className="lg:col-span-2">
<TasksWidget /> <CalendarWidget />
</div>
<div className="lg:col-span-1">
<TasksWidget />
</div>
</div> </div>
{/* Claude row */} {/* Claude row */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-5">
<ClaudeUsageWidget /> <ClaudeUsageWidget />
<ClaudeApiWidget /> <ClaudeApiWidget />
</div> </div>

View File

@@ -20,54 +20,44 @@ interface Metrics {
network: { rxBytes: number | null; txBytes: number | null }; network: { rxBytes: number | null; txBytes: number | null };
} }
function GaugeChart({ value, label, color }: { value: number; label: string; color: string }) { function CircularGauge({ value, size = 96, color, label }: { value: number; size?: number; color: string; label: string }) {
const r = 36; const radius = (size - 14) / 2;
const circ = 2 * Math.PI * r; const circumference = 2 * Math.PI * radius;
const half = circ / 2; const offset = circumference - (value / 100) * circumference;
const offset = half - (value / 100) * half;
return ( return (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="relative w-24 h-14 overflow-hidden"> <div className="relative" style={{ width: size, height: size }}>
<svg width="96" height="56" className="absolute bottom-0"> <svg width={size} height={size} className="-rotate-90">
<path <circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="10" />
d={`M 8 52 A ${r} ${r} 0 0 1 88 52`} <circle
fill="none" cx={size/2} cy={size/2} r={radius}
stroke="rgb(30,41,59)" fill="none" stroke={color}
strokeWidth="10" strokeWidth="10" strokeLinecap="round"
strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
/> style={{ transition: "stroke-dashoffset 0.8s ease", filter: `drop-shadow(0 0 6px ${color}60)` }}
<path
d={`M 8 52 A ${r} ${r} 0 0 1 88 52`}
fill="none"
stroke={color}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${half}`}
strokeDashoffset={`${offset}`}
style={{ transition: "stroke-dashoffset 0.6s ease" }}
/> />
</svg> </svg>
<div className="absolute bottom-0 w-full text-center pb-1"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold text-white">{value}%</span> <span className="text-lg font-bold text-white">{value}%</span>
</div> </div>
</div> </div>
<span className="text-xs text-slate-400">{label}</span> <span className="text-xs text-slate-400 font-medium">{label}</span>
</div> </div>
); );
} }
function UsageBar({ label, value, color, detail }: { label: string; value: number; color: string; detail?: string }) { function UsageBar({ label, value, color, detail }: { label: string; value: number; color: string; detail?: string }) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-2">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-slate-400">{label}</span> <span className="text-slate-400 font-medium">{label}</span>
<span className="text-slate-300">{detail || `${value}%`}</span> <span className="text-white font-semibold">{detail || `${value}%`}</span>
</div> </div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden"> <div className="h-1.5 rounded-full overflow-hidden" style={{ background: "rgba(255,255,255,0.05)" }}>
<div <div
className="h-full rounded-full transition-all duration-700" className="h-full rounded-full transition-all duration-700"
style={{ width: `${value}%`, backgroundColor: color }} style={{ width: `${value}%`, background: color, boxShadow: `0 0 8px ${color}80` }}
/> />
</div> </div>
</div> </div>
@@ -112,18 +102,19 @@ export default function SystemPage() {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between pt-2">
<div> <div>
<h1 className="text-2xl font-bold text-white">System Monitor</h1> <h1 className="text-3xl font-bold text-white">System Monitor</h1>
<p className="text-slate-400 text-sm"> <p className="text-slate-500 text-sm mt-1">
{lastUpdated ? `Обновлено: ${lastUpdated.toLocaleTimeString("ru-RU")}` : "Загрузка..."} {lastUpdated ? `Обновлено: ${lastUpdated.toLocaleTimeString("ru-RU")}` : "Загрузка..."}
</p> </p>
</div> </div>
<button <button
onClick={fetchMetrics} onClick={fetchMetrics}
disabled={loading} disabled={loading}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-xl border border-white/10 text-slate-300 text-sm transition-all hover:border-white/20 hover:bg-white/5 disabled:opacity-50"
style={{ background: "rgba(255,255,255,0.03)" }}
> >
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Обновить Обновить
@@ -131,16 +122,17 @@ export default function SystemPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 bg-slate-800/50 p-1 rounded-lg w-fit"> <div className="flex gap-1 p-1 rounded-xl w-fit border border-white/5" style={{ background: "rgba(255,255,255,0.03)" }}>
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${ className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab activeTab === tab
? "bg-indigo-600 text-white shadow" ? "text-white"
: "text-slate-400 hover:text-slate-200" : "text-slate-500 hover:text-slate-300"
}`} }`}
style={activeTab === tab ? { background: "linear-gradient(135deg, #6366f1, #8b5cf6)" } : {}}
> >
{tab} {tab}
</button> </button>
@@ -148,30 +140,30 @@ export default function SystemPage() {
</div> </div>
{error ? ( {error ? (
<div className="glass-card p-8 text-center"> <div className="card p-8 text-center">
<div className="text-red-400 text-sm mb-2"> Не удалось получить метрики</div> <div className="text-red-400 text-sm mb-2"> Не удалось получить метрики</div>
<div className="text-slate-500 text-xs">Убедитесь что node_exporter запущен на хосте</div> <div className="text-slate-600 text-xs">Убедитесь что node_exporter запущен на хосте</div>
<button onClick={fetchMetrics} className="mt-4 text-indigo-400 text-sm hover:underline"> <button onClick={fetchMetrics} className="mt-4 text-indigo-400 text-sm hover:text-indigo-300 transition-colors">
Попробовать снова Попробовать снова
</button> </button>
</div> </div>
) : ( ) : (
<> <>
{/* Gauges row */} {/* Circular gauges */}
<div className="glass-card p-6"> <div className="card card-accent-violet p-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-6">
<Activity className="w-4 h-4 text-indigo-400" /> <Activity className="w-4 h-4 text-violet-400" />
<span className="text-sm font-medium text-slate-300">Загрузка</span> <span className="text-sm font-semibold text-white">Загрузка системы</span>
</div> </div>
{loading ? ( {loading ? (
<div className="flex gap-8 justify-center animate-pulse"> <div className="flex gap-12 justify-center animate-pulse">
{[1,2,3].map(i => <div key={i} className="w-24 h-20 bg-slate-700/50 rounded" />)} {[1,2,3].map(i => <div key={i} className="w-24 h-28 bg-white/5 rounded-full" />)}
</div> </div>
) : ( ) : (
<div className="flex flex-wrap gap-8 justify-around"> <div className="flex flex-wrap gap-10 justify-around">
<GaugeChart value={metrics?.cpu ?? 0} label="CPU" color="#6366f1" /> <CircularGauge value={metrics?.cpu ?? 0} label="CPU" color="#6366f1" />
<GaugeChart value={metrics?.ram?.percent ?? 0} label="RAM" color="#8b5cf6" /> <CircularGauge value={metrics?.ram?.percent ?? 0} label="RAM" color="#8b5cf6" />
<GaugeChart value={metrics?.disk?.percent ?? 0} label="Disk" color="#10b981" /> <CircularGauge value={metrics?.disk?.percent ?? 0} label="Disk" color="#10b981" />
</div> </div>
)} )}
</div> </div>
@@ -179,90 +171,51 @@ export default function SystemPage() {
{/* Stats grid */} {/* Stats grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
{ { icon: Cpu, label: "CPU", value: loading ? "..." : `${metrics?.cpu ?? "—"}%`, sub: loading ? "" : `Load: ${metrics?.load1?.toFixed(2) ?? "—"} · ${metrics?.cpuCount ?? "?"} cores`, color: "text-indigo-400", accent: "card-accent-blue" },
icon: Cpu, { icon: MemoryStick, label: "RAM", value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`, color: "text-violet-400", accent: "card-accent-violet" },
label: "CPU", { icon: HardDrive, label: "Disk", value: loading ? "..." : `${metrics?.disk?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.disk?.total ?? "?"} GB`, color: "text-emerald-400", accent: "card-accent-emerald" },
value: loading ? "..." : `${metrics?.cpu ?? "—"}%`, { icon: Clock, label: "Uptime", value: loading ? "..." : (metrics?.uptime ?? "—"), sub: "Время работы", color: "text-amber-400", accent: "card-accent-amber" },
sub: loading ? "" : `Load: ${metrics?.load1?.toFixed(2) ?? "—"} · ${metrics?.cpuCount ?? "?"} cores`,
color: "text-indigo-400",
},
{
icon: MemoryStick,
label: "RAM",
value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`,
sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`,
color: "text-violet-400",
},
{
icon: HardDrive,
label: "Disk",
value: loading ? "..." : `${metrics?.disk?.used ?? "—"} GB`,
sub: loading ? "" : `из ${metrics?.disk?.total ?? "?"} GB`,
color: "text-emerald-400",
},
{
icon: Clock,
label: "Uptime",
value: loading ? "..." : (metrics?.uptime ?? "—"),
sub: "Время работы",
color: "text-amber-400",
},
].map((item) => ( ].map((item) => (
<div key={item.label} className="glass-card p-5"> <div key={item.label} className={`card ${item.accent} p-5`}>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<item.icon className={`w-4 h-4 ${item.color}`} /> <item.icon className={`w-4 h-4 ${item.color}`} />
<span className="text-sm text-slate-400">{item.label}</span> <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{item.label}</span>
</div> </div>
<div className="text-2xl font-bold text-white">{item.value}</div> <div className="text-2xl font-bold text-white">{item.value}</div>
{item.sub && <div className="text-xs text-slate-500 mt-1">{item.sub}</div>} {item.sub && <div className="text-xs text-slate-600 mt-1">{item.sub}</div>}
</div> </div>
))} ))}
</div> </div>
{/* Usage bars */} {/* Usage bars */}
{!loading && metrics && ( {!loading && metrics && (
<div className="glass-card p-5 space-y-4"> <div className="card p-5 space-y-5">
<h3 className="text-sm font-medium text-slate-300">Использование ресурсов</h3> <h3 className="text-sm font-semibold text-white">Использование ресурсов</h3>
<UsageBar <UsageBar label="CPU" value={metrics.cpu ?? 0} color="#6366f1" detail={`${metrics.cpu ?? "—"}%`} />
label="CPU"
value={metrics.cpu ?? 0}
color="#6366f1"
detail={`${metrics.cpu ?? "—"}%`}
/>
{metrics.ram && ( {metrics.ram && (
<UsageBar <UsageBar label="RAM" value={metrics.ram.percent} color="#8b5cf6" detail={`${metrics.ram.used} / ${metrics.ram.total} GB`} />
label="RAM"
value={metrics.ram.percent}
color="#8b5cf6"
detail={`${metrics.ram.used} / ${metrics.ram.total} GB`}
/>
)} )}
{metrics.disk && ( {metrics.disk && (
<UsageBar <UsageBar label="Disk" value={metrics.disk.percent} color="#10b981" detail={`${metrics.disk.used} / ${metrics.disk.total} GB`} />
label="Disk"
value={metrics.disk.percent}
color="#10b981"
detail={`${metrics.disk.used} / ${metrics.disk.total} GB`}
/>
)} )}
</div> </div>
)} )}
{/* Network */} {/* Network */}
{!loading && metrics?.network && ( {!loading && metrics?.network && (
<div className="glass-card p-5"> <div className="card card-accent-cyan p-5">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-4">
<Wifi className="w-4 h-4 text-cyan-400" /> <Wifi className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium text-slate-300">Сеть (всего)</span> <span className="text-sm font-semibold text-white">Сеть (всего)</span>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div className="rounded-xl p-3 border border-white/5" style={{ background: "rgba(6,182,212,0.05)" }}>
<div className="text-xs text-slate-500 mb-1"> Получено</div> <div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider"> Получено</div>
<div className="text-lg font-semibold text-white">{formatBytes(metrics.network.rxBytes)}</div> <div className="text-xl font-bold text-white">{formatBytes(metrics.network.rxBytes)}</div>
</div> </div>
<div> <div className="rounded-xl p-3 border border-white/5" style={{ background: "rgba(6,182,212,0.05)" }}>
<div className="text-xs text-slate-500 mb-1"> Отправлено</div> <div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider"> Отправлено</div>
<div className="text-lg font-semibold text-white">{formatBytes(metrics.network.txBytes)}</div> <div className="text-xl font-bold text-white">{formatBytes(metrics.network.txBytes)}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,27 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 224 71% 4%; --background: 240 10% 4%;
--foreground: 213 31% 91%; --foreground: 210 40% 98%;
--card: 224 71% 4%; --card: 240 10% 4%;
--card-foreground: 213 31% 91%; --card-foreground: 210 40% 98%;
--border: 216 34% 17%; --border: 240 6% 12%;
--input: 216 34% 17%; --input: 240 6% 12%;
--primary: 210 40% 98%; --primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%; --primary-foreground: 240 10% 4%;
--secondary: 222.2 47.4% 11.2%; --secondary: 240 6% 10%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--muted: 223 47% 11%; --muted: 240 6% 9%;
--muted-foreground: 215.4 16.3% 56.9%; --muted-foreground: 215 16% 47%;
--accent: 216 34% 17%; --accent: 240 6% 12%;
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%; --destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%; --ring: 240 6% 12%;
--radius: 0.5rem; --radius: 0.75rem;
--bg-base: #0a0a0f;
--bg-surface: #111118;
--bg-elevated: #1a1a24;
} }
} }
@@ -30,56 +35,87 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; background-color: var(--bg-base);
background: radial-gradient(ellipse at top left, rgba(99,102,241,0.08) 0%, transparent 50%), font-family: 'Inter', system-ui, sans-serif;
radial-gradient(ellipse at bottom right, rgba(139,92,246,0.05) 0%, transparent 50%), color: #f8fafc;
hsl(224, 71%, 4%);
min-height: 100vh; min-height: 100vh;
} }
} }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
/* Card base style */
.card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
backdrop-filter: blur(10px);
transition: all 0.2s ease;
}
.card:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.1);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
/* Legacy compat */
.glass-card { .glass-card {
background: rgba(15, 23, 42, 0.7); background: rgba(255,255,255,0.03);
backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.06);
border: 1px solid rgba(99, 102, 241, 0.15); border-radius: 16px;
border-radius: 12px; backdrop-filter: blur(10px);
transition: all 0.2s ease;
} }
.glass-card:hover { .glass-card:hover {
border-color: rgba(99, 102, 241, 0.3); background: rgba(255,255,255,0.05);
transition: border-color 0.2s ease; border-color: rgba(255,255,255,0.1);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
} }
.card-accent-blue { border-top: 2px solid #3b82f6; }
.card-accent-violet { border-top: 2px solid #8b5cf6; }
.card-accent-emerald { border-top: 2px solid #10b981; }
.card-accent-amber { border-top: 2px solid #f59e0b; }
.card-accent-cyan { border-top: 2px solid #06b6d4; }
.card-accent-rose { border-top: 2px solid #f43f5e; }
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #6366f1, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Glow effects */
.glow-violet { box-shadow: 0 0 30px rgba(139,92,246,0.2); }
.glow-blue { box-shadow: 0 0 30px rgba(59,130,246,0.2); }
/* Sidebar */
.sidebar { .sidebar {
background: rgba(10, 17, 32, 0.85); background: #0d0d14;
backdrop-filter: blur(20px); border-right: 1px solid rgba(255,255,255,0.05);
border-right: 1px solid rgba(99, 102, 241, 0.12);
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.5);
} }
/* Status indicators */
.status-online { .status-online {
@apply bg-green-500; background: #10b981;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); box-shadow: 0 0 6px rgba(16,185,129,0.5);
} }
.status-offline { .status-offline {
@apply bg-red-500; background: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); box-shadow: 0 0 6px rgba(239,68,68,0.5);
}
.status-checking {
background: #6b7280;
} }
.status-checking { @keyframes status-ring {
@apply bg-yellow-500; 0%, 100% { opacity: 0.7; transform: scale(1); }
50% { opacity: 0; transform: scale(2.5); }
} }

View File

@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { Home, Server, Bookmark, LogOut, LayoutDashboard } from "lucide-react"; import { LayoutDashboard, Server, Bookmark, LogOut } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
@@ -17,20 +17,26 @@ export function Sidebar({ userName }: { userName?: string | null }) {
return ( return (
<aside className="sidebar w-64 min-h-screen flex flex-col fixed left-0 top-0 z-50"> <aside className="sidebar w-64 min-h-screen flex flex-col fixed left-0 top-0 z-50">
{/* Logo */} {/* Logo */}
<div className="p-6 border-b border-indigo-500/10"> <div className="p-5 border-b border-white/5">
<Link href="/" className="flex items-center gap-3 group"> <Link href="/" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center group-hover:bg-indigo-500/30 transition-colors"> <div
<Home className="w-5 h-5 text-indigo-400" /> className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold text-white flex-shrink-0"
style={{ background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)" }}
>
DH
</div> </div>
<div> <div>
<div className="text-sm font-bold text-white">Digital Home</div> <div className="text-sm font-bold text-white">Digital Home</div>
<div className="text-xs text-slate-500">Dashboard</div> <div className="text-[10px] text-slate-500 uppercase tracking-wider">Dashboard</div>
</div> </div>
</Link> </Link>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 p-4 space-y-1"> <nav className="flex-1 p-3 space-y-0.5 mt-2">
<div className="px-3 mb-2">
<span className="text-[10px] font-semibold text-slate-600 uppercase tracking-widest">Navigation</span>
</div>
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); const isActive = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
@@ -39,35 +45,36 @@ export function Sidebar({ userName }: { userName?: string | null }) {
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all", "flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
isActive isActive
? "bg-indigo-500/20 text-indigo-300 border border-indigo-500/20" ? "bg-gradient-to-r from-indigo-500/20 to-violet-500/10 border-l-2 border-indigo-500 text-white pl-[10px]"
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/50" : "text-slate-500 hover:text-slate-300 hover:bg-white/5"
)} )}
> >
<Icon className="w-4 h-4 shrink-0" /> <Icon className={cn("w-4 h-4 shrink-0", isActive ? "text-indigo-400" : "")} />
{item.label} {item.label}
</Link> </Link>
); );
})} })}
</nav> </nav>
{/* User */} {/* User section */}
<div className="p-4 border-t border-slate-800"> <div className="p-3 border-t border-white/5">
<div className="flex items-center gap-3 px-2 mb-2"> <div className="flex items-center gap-3 px-3 py-2.5 rounded-xl mb-1">
<div className="w-8 h-8 rounded-full bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center"> <div
<span className="text-xs font-bold text-indigo-400"> className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
{userName?.[0]?.toUpperCase() ?? "D"} style={{ background: "linear-gradient(135deg, #6366f1, #8b5cf6)" }}
</span> >
{userName?.[0]?.toUpperCase() ?? "D"}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">{userName ?? "Daniil"}</div> <div className="text-sm font-medium text-white truncate">{userName ?? "Daniil"}</div>
<div className="text-xs text-slate-500">Administrator</div> <div className="text-[10px] text-slate-500">Administrator</div>
</div> </div>
</div> </div>
<button <button
onClick={() => signOut({ callbackUrl: "/auth/signin" })} onClick={() => signOut({ callbackUrl: "/auth/signin" })}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-all" className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-sm text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-all"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Выйти Выйти

View File

@@ -35,11 +35,8 @@ export function CalendarWidget() {
try { try {
const res = await fetch(`/api/calendar?month=${monthStr}`); const res = await fetch(`/api/calendar?month=${monthStr}`);
const data = await res.json(); const data = await res.json();
if (data.error && data.events?.length === 0) { if (data.error && data.events?.length === 0) setCalError(data.error);
setCalError(data.error); else setCalError(null);
} else {
setCalError(null);
}
setMonthEvents(data.events ?? []); setMonthEvents(data.events ?? []);
} catch { setMonthEvents([]); } } catch { setMonthEvents([]); }
}, [monthStr]); }, [monthStr]);
@@ -47,8 +44,7 @@ export function CalendarWidget() {
useEffect(() => { fetchMonth(); }, [fetchMonth]); useEffect(() => { fetchMonth(); }, [fetchMonth]);
const fetchDay = async (dateStr: string) => { const fetchDay = async (dateStr: string) => {
setLoadingDay(true); setLoadingDay(true); setDayEvents([]);
setDayEvents([]);
try { try {
const res = await fetch(`/api/calendar?date=${dateStr}`); const res = await fetch(`/api/calendar?date=${dateStr}`);
const data = await res.json(); const data = await res.json();
@@ -73,9 +69,7 @@ export function CalendarWidget() {
const startOffset = firstDay === 0 ? 6 : firstDay - 1; const startOffset = firstDay === 0 ? 6 : firstDay - 1;
const cells = Array(startOffset).fill(null).concat(Array.from({ length: daysInMonth }, (_, i) => i + 1)); const cells = Array(startOffset).fill(null).concat(Array.from({ length: daysInMonth }, (_, i) => i + 1));
const datesWithEvents = new Set( const datesWithEvents = new Set(monthEvents.map(e => (e.start || "").split("T")[0]));
monthEvents.map(e => (e.start || "").split("T")[0])
);
const createEvent = async () => { const createEvent = async () => {
if (!newEvent.title.trim() || !selectedDate) return; if (!newEvent.title.trim() || !selectedDate) return;
@@ -84,13 +78,7 @@ export function CalendarWidget() {
const res = await fetch("/api/calendar", { const res = await fetch("/api/calendar", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ title: newEvent.title, date: selectedDate, startTime: newEvent.startTime, endTime: newEvent.endTime, allDay: newEvent.allDay }),
title: newEvent.title,
date: selectedDate,
startTime: newEvent.startTime,
endTime: newEvent.endTime,
allDay: newEvent.allDay,
}),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error); 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 }); setNewEvent({ title: "", startTime: "09:00", endTime: "10:00", allDay: false });
fetchDay(selectedDate); fetchDay(selectedDate);
fetchMonth(); fetchMonth();
} catch (e: any) { setCreateError(e.message || "Ошибка создания"); } } catch (e: unknown) { setCreateError(e instanceof Error ? e.message : "Ошибка создания"); }
finally { setCreating(false); } finally { setCreating(false); }
}; };
@@ -106,30 +94,31 @@ export function CalendarWidget() {
if (calError === "Google Calendar not configured") { if (calError === "Google Calendar not configured") {
return ( 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-4xl">📅</div>
<div className="text-sm font-medium text-white">Google Calendar</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-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> </div>
); );
} }
return ( 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"> <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" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
<span className="text-sm font-semibold text-white">{MONTHS[month]} {year}</span> <span className="text-sm font-semibold gradient-text">{MONTHS[month]} {year}</span>
<button onClick={nextMonth} className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors"> <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" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
{/* Weekday labels */}
<div className="grid grid-cols-7 gap-0.5"> <div className="grid grid-cols-7 gap-0.5">
{WEEK_DAYS.map(d => ( {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) => { {cells.map((day, i) => {
if (!day) return <div key={`empty-${i}`} />; if (!day) return <div key={`empty-${i}`} />;
@@ -141,39 +130,44 @@ export function CalendarWidget() {
<button <button
key={dateStr} key={dateStr}
onClick={() => handleDayClick(day)} onClick={() => handleDayClick(day)}
className={`relative flex flex-col items-center justify-center aspect-square rounded-lg text-sm transition-all ${ className={`relative flex flex-col items-center justify-center aspect-square rounded-xl text-sm transition-all ${
isSelected ? "bg-indigo-600 text-white font-semibold" : isSelected
isToday ? "bg-slate-700 text-indigo-300 font-semibold ring-1 ring-indigo-500" : ? "text-white font-semibold"
"text-slate-300 hover:bg-slate-700/50" : 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} {day}
{hasEvents && ( {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> </button>
); );
})} })}
</div> </div>
{/* Day events panel */}
{selectedDate && ( {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"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400"> <span className="text-xs font-medium text-slate-400">
{new Date(selectedDate + "T12:00:00").toLocaleDateString("ru-RU", { day: "numeric", month: "long" })} {new Date(selectedDate + "T12:00:00").toLocaleDateString("ru-RU", { day: "numeric", month: "long" })}
</span> </span>
<button <button
onClick={() => setShowCreate(s => !s)} 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" /> Добавить <Plus className="w-3 h-3" /> Добавить
</button> </button>
</div> </div>
{showCreate && ( {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 <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="Название события" placeholder="Название события"
value={newEvent.title} value={newEvent.title}
onChange={e => setNewEvent(n => ({ ...n, title: e.target.value }))} onChange={e => setNewEvent(n => ({ ...n, title: e.target.value }))}
@@ -181,7 +175,7 @@ export function CalendarWidget() {
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer"> <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 }))} /> onChange={e => setNewEvent(n => ({ ...n, allDay: e.target.checked }))} />
Весь день Весь день
</label> </label>
@@ -189,19 +183,20 @@ export function CalendarWidget() {
{!newEvent.allDay && ( {!newEvent.allDay && (
<div className="flex gap-2"> <div className="flex gap-2">
<input type="time" value={newEvent.startTime} onChange={e => setNewEvent(n => ({ ...n, startTime: e.target.value }))} <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" /> 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-500 self-center"></span> <span className="text-slate-600 self-center"></span>
<input type="time" value={newEvent.endTime} onChange={e => setNewEvent(n => ({ ...n, endTime: e.target.value }))} <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> </div>
)} )}
{createError && <div className="text-xs text-red-400">{createError}</div>} {createError && <div className="text-xs text-red-400">{createError}</div>}
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={createEvent} disabled={creating || !newEvent.title.trim()} <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 ? "Создаю..." : "Создать"} {creating ? "Создаю..." : "Создать"}
</button> </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" /> <X className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
@@ -210,18 +205,19 @@ export function CalendarWidget() {
{loadingDay ? ( {loadingDay ? (
<div className="space-y-1.5 animate-pulse"> <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> </div>
) : dayEvents.length === 0 ? ( ) : dayEvents.length === 0 ? (
<div className="text-xs text-slate-600 text-center py-2">Нет событий</div> <div className="text-xs text-slate-600 text-center py-2">Нет событий</div>
) : ( ) : (
<div className="space-y-1.5 max-h-40 overflow-y-auto"> <div className="space-y-1.5 max-h-40 overflow-y-auto">
{dayEvents.map(ev => ( {dayEvents.map(ev => (
<div key={ev.id} className="flex items-start gap-2 p-2 rounded-lg bg-slate-800/40 group"> <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-1 h-full min-h-[32px] rounded-full bg-violet-500 mt-0.5 flex-shrink-0" /> <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="flex-1 min-w-0">
<div className="text-xs font-medium text-white truncate">{ev.title}</div> <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" /> <Clock className="w-2.5 h-2.5" />
{ev.allDay ? "Весь день" : formatTime(ev.start)} {ev.allDay ? "Весь день" : formatTime(ev.start)}
{ev.end && !ev.allDay && `${formatTime(ev.end)}`} {ev.end && !ev.allDay && `${formatTime(ev.end)}`}
@@ -229,7 +225,7 @@ export function CalendarWidget() {
</div> </div>
{ev.htmlLink && ( {ev.htmlLink && (
<a href={ev.htmlLink} target="_blank" rel="noopener noreferrer" <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" /> <ExternalLink className="w-3 h-3" />
</a> </a>
)} )}

View File

@@ -11,42 +11,38 @@ export function ClaudeApiWidget() {
}; };
return ( return (
<div className="glass-card p-5"> <div className="card card-accent-violet p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<div className="w-5 h-5 rounded bg-purple-500/20 flex items-center justify-center"> <div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
<span className="text-xs font-bold text-purple-400">A</span> 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> </div>
<span className="text-sm font-medium text-slate-300">Claude API Usage & Cost</span>
</div> </div>
<button <button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
onClick={handleRefresh} {refreshing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
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> </button>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ label: "Стоимость", value: "—" }, { label: "Стоимость", value: "—", color: "text-violet-400" },
{ label: "Запросы", value: "—" }, { label: "Запросы", value: "—", color: "text-white" },
{ label: "Токены", value: "—" }, { label: "Токены", value: "—", color: "text-white" },
].map((item) => ( ].map((item) => (
<div key={item.label} className="bg-slate-800/40 rounded-lg p-3 text-center"> <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 text-white">{item.value}</div> <div className={`text-xl font-bold ${item.color}`}>{item.value}</div>
<div className="text-xs text-slate-400 mt-1">{item.label}</div> <div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-3 flex items-center gap-2 text-xs text-slate-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-yellow-500" /> <div className="w-1.5 h-1.5 rounded-full bg-violet-500/50" />
<span>Автоматическое получение данных недоступно</span> <span>Автоматическое получение данных недоступно</span>
</div> </div>
</div> </div>

View File

@@ -11,42 +11,38 @@ export function ClaudeUsageWidget() {
}; };
return ( return (
<div className="glass-card p-5"> <div className="card card-accent-amber p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<div className="w-5 h-5 rounded bg-orange-500/20 flex items-center justify-center"> <div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
<span className="text-xs font-bold text-orange-400">C</span> 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> </div>
<span className="text-sm font-medium text-slate-300">Claude Subscription Usage</span>
</div> </div>
<button <button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
onClick={handleRefresh} {refreshing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
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> </button>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ label: "Использовано", value: "—" }, { label: "Использовано", value: "—", color: "text-amber-400" },
{ label: "Лимит", value: "—" }, { label: "Лимит", value: "—", color: "text-white" },
{ label: "Сброс", value: "—" }, { label: "Сброс", value: "—", color: "text-white" },
].map((item) => ( ].map((item) => (
<div key={item.label} className="bg-slate-800/40 rounded-lg p-3 text-center"> <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 text-white">{item.value}</div> <div className={`text-xl font-bold ${item.color}`}>{item.value}</div>
<div className="text-xs text-slate-400 mt-1">{item.label}</div> <div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-3 flex items-center gap-2 text-xs text-slate-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-yellow-500" /> <div className="w-1.5 h-1.5 rounded-full bg-amber-500/50" />
<span>Автоматическое получение данных недоступно</span> <span>Автоматическое получение данных недоступно</span>
</div> </div>
</div> </div>

View 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>
);
}

View File

@@ -13,11 +13,13 @@ interface Service {
interface ServiceCategory { interface ServiceCategory {
label: string; label: string;
services: Service[]; services: Service[];
accent: string;
} }
const SERVICE_CATEGORIES: ServiceCategory[] = [ const SERVICE_CATEGORIES: ServiceCategory[] = [
{ {
label: "Productivity", label: "Productivity",
accent: "text-emerald-400",
services: [ services: [
{ name: "Pulse", url: "https://pulse.digital-home.site", icon: "https://pulse.digital-home.site/favicon.svg", desc: "Привычки и задачи", emoji: "💓" }, { 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: "🐙" }, { name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" },
@@ -25,6 +27,7 @@ const SERVICE_CATEGORIES: ServiceCategory[] = [
}, },
{ {
label: "Storage", label: "Storage",
accent: "text-blue-400",
services: [ services: [
{ name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" }, { name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" },
{ name: "Immich", url: "https://photo.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", label: "Tools",
accent: "text-cyan-400",
services: [ services: [
{ name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" }, { name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" },
{ name: "IT-Tools", url: "https://tools.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", label: "AI Subscribe",
accent: "text-violet-400",
services: [ services: [
{ name: "OpenAI Usage", url: "https://chatgpt.com/codex/cloud/settings/usage", desc: "Лимиты OpenAI", emoji: "🤖" }, { 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: "✨" }, { name: "Claude Usage", url: "https://claude.ai/settings/usage", desc: "Лимиты Claude", emoji: "✨" },
@@ -49,6 +54,7 @@ const SERVICE_CATEGORIES: ServiceCategory[] = [
}, },
{ {
label: "AI API", label: "AI API",
accent: "text-amber-400",
services: [ services: [
{ name: "Moonshot AI", url: "https://platform.moonshot.ai/console/account", desc: "Баланс и API ключи", emoji: "🌙" }, { 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: "📈" }, { 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} href={service.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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 ? ( {service.icon ? (
// eslint-disable-next-line @next/next/no-img-element // 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) => { <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).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> <span>{service.emoji ?? "🔗"}</span>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <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> <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> </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>
<div className="shrink-0"> <div className="shrink-0 relative">
<div {status === "online" && (
className={`w-2 h-2 rounded-full ${ <span className="absolute inset-0 rounded-full bg-emerald-400 opacity-40 animate-ping" />
status === "online" ? "status-online" : )}
status === "offline" ? "status-offline" : <span className={`relative block w-2 h-2 rounded-full ${
"status-checking animate-pulse" status === "online" ? "bg-emerald-500" :
}`} status === "offline" ? "bg-red-500" :
/> "bg-slate-600 animate-pulse"
}`} />
</div> </div>
</a> </a>
); );
@@ -115,10 +124,13 @@ function ServiceCard({ service }: { service: Service }) {
export function ServicesGrid() { export function ServicesGrid() {
return ( return (
<div className="space-y-6"> <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) => ( {SERVICE_CATEGORIES.map((cat) => (
<div key={cat.label}> <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"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{cat.services.map((svc) => ( {cat.services.map((svc) => (
<ServiceCard key={svc.url} service={svc} /> <ServiceCard key={svc.url} service={svc} />

View File

@@ -29,45 +29,48 @@ export function TasksWidget() {
useEffect(() => { fetchTasks(); }, []); useEffect(() => { fetchTasks(); }, []);
const priorityColor: Record<string, string> = { const priorityDot: Record<string, string> = {
high: "text-red-400", high: "bg-red-500",
medium: "text-yellow-400", medium: "bg-amber-500",
low: "text-green-400", low: "bg-emerald-500",
}; };
return ( 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 justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-emerald-400" /> <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> </div>
<button onClick={fetchTasks} className="text-slate-500 hover:text-slate-300 transition-colors"> <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" /> <RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<div className="space-y-2 animate-pulse"> <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> </div>
) : tasks.length === 0 ? ( ) : 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" /> <CheckSquare className="w-8 h-8 mb-2 opacity-30" />
<span className="text-sm">Задач нет</span> <span className="text-sm">Задач нет</span>
</div> </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) => ( {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 ? ( {task.done ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" /> <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} {task.title}
</span> </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>
))} ))}
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RefreshCw, Wind, Droplets, Thermometer } from "lucide-react"; import { RefreshCw } from "lucide-react";
interface CurrentWeather { interface CurrentWeather {
temp: number; feelsLike: number; humidity: number; windKmh: number; desc: string; icon: string; temp: number; feelsLike: number; humidity: number; windKmh: number; desc: string; icon: string;
@@ -14,7 +14,6 @@ interface WeatherData {
} }
const DAY_NAMES = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]; 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);
@@ -36,80 +35,98 @@ export function WeatherWidget() {
const c = data?.current; const c = data?.current;
const day = data?.forecast?.[selected]; 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"); 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 ( return (
<div className="glass-card p-5 flex flex-col gap-4"> <div className="card card-accent-blue relative overflow-hidden">
<div className="flex items-center justify-between"> {/* Decorative background */}
<span className="text-sm font-medium text-slate-300">Погода · Санкт-Петербург</span> <div className="absolute inset-0 bg-gradient-to-br from-blue-900/20 via-transparent to-violet-900/10 pointer-events-none" />
<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>
{error ? ( <div className="relative p-6">
<div className="text-slate-500 text-sm text-center py-4">Нет данных</div> {/* Header */}
) : loading ? ( <div className="flex items-center justify-between mb-5">
<div className="space-y-3 animate-pulse"> <div className="flex items-center gap-2">
<div className="h-12 bg-slate-700/50 rounded w-32" /> <span className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Погода</span>
<div className="h-4 bg-slate-700/50 rounded w-48" /> <span className="text-xs text-slate-500">· Санкт-Петербург</span>
<div className="flex gap-2">{[...Array(7)].map((_,i)=><div key={i} className="h-20 bg-slate-700/50 rounded flex-1"/>)}</div> </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> </div>
) : c && day ? (
<> {error ? (
<div className="flex items-center gap-4"> <div className="text-slate-500 text-sm text-center py-8">Нет данных о погоде</div>
<span className="text-5xl">{selected === 0 ? c.icon : day.icon}</span> ) : loading ? (
<div> <div className="space-y-4 animate-pulse">
<div className="flex items-baseline gap-1"> <div className="flex items-end gap-4">
<span className="text-4xl font-bold text-white"> <div className="h-20 w-20 bg-white/5 rounded-2xl" />
{selected === 0 ? c.temp : day.maxTemp}° <div className="space-y-2">
</span> <div className="h-12 w-28 bg-white/5 rounded-lg" />
{selected !== 0 && ( <div className="h-4 w-40 bg-white/5 rounded" />
<span className="text-slate-400 text-lg">{day.minTemp}°</span> </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>
<div className="text-slate-300 text-sm">{selected === 0 ? c.desc : day.desc}</div> {selected === 0 && (
<div className="text-xs text-slate-500">{selected === 0 ? "Сейчас" : formatDate(day.date)}</div> <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> </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"> {/* 7-day forecast */}
{data.forecast.map((f, i) => { <div className="grid grid-cols-7 gap-1.5">
const d = new Date(f.date + "T12:00:00"); {data!.forecast.map((f, i) => (
const isToday = i === 0;
return (
<button <button
key={f.date} key={f.date}
onClick={() => setSelected(i)} 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 selected === i
? "bg-indigo-600/60 ring-1 ring-indigo-500" ? "bg-white/10 ring-1 ring-white/20"
: "bg-slate-800/40 hover:bg-slate-700/50" : "hover:bg-white/5"
}`} }`}
> >
<span className="text-xs text-slate-400">{isToday ? "Сег" : DAY_NAMES[d.getDay()]}</span> <span className="text-[10px] font-medium text-slate-400">{formatDay(f.date, i)}</span>
<span className="text-lg">{f.icon}</span> <span className="text-xl">{f.icon}</span>
<span className="text-xs font-medium text-white">{f.maxTemp}°</span> <span className="text-xs font-semibold text-white">{f.maxTemp}°</span>
<span className="text-xs text-slate-500">{f.minTemp}°</span> <span className="text-[10px] text-slate-600">{f.minTemp}°</span>
{f.precipProb > 20 && ( {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> </button>
); ))}
})} </div>
</div> </>
</> ) : null}
) : null} </div>
</div> </div>
); );
} }

View File

@@ -35,6 +35,21 @@ const config: Config = {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
dash: {
bg: "#0a0a0f",
surface: "#111118",
elevated: "#1a1a24",
border: "rgba(255,255,255,0.06)",
},
},
backgroundImage: {
"gradient-primary": "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
"gradient-blue": "linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%)",
"gradient-emerald": "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
"gradient-amber": "linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",
@@ -50,10 +65,26 @@ const config: Config = {
from: { height: "var(--radix-accordion-content-height)" }, from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" }, to: { height: "0" },
}, },
glow: {
"0%": { boxShadow: "0 0 5px rgba(99,102,241,0.3)" },
"100%": { boxShadow: "0 0 20px rgba(99,102,241,0.6)" },
},
float: {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-6px)" },
},
"status-pulse": {
"0%, 100%": { opacity: "1", transform: "scale(1)" },
"50%": { opacity: "0.4", transform: "scale(1.5)" },
},
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
glow: "glow 2s ease-in-out infinite alternate",
float: "float 6s ease-in-out infinite",
"status-pulse": "status-pulse 2s ease-in-out infinite",
}, },
}, },
}, },