redesign: modern dark dashboard WOW effect - gradients, animations, new layout
This commit is contained in:
@@ -67,16 +67,16 @@ export default function BookmarksPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-5 max-w-7xl mx-auto">
|
||||||
<div className="pt-2 pb-2">
|
<div className="pt-1 pb-1">
|
||||||
<h1 className="text-3xl font-bold text-white">Bookmarks</h1>
|
<h1 className="text-2xl font-bold text-white">Bookmarks</h1>
|
||||||
<p className="text-slate-500 text-sm mt-1">Все ссылки в одном месте</p>
|
<p className="text-slate-500 text-sm mt-0.5">Все ссылки в одном месте</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div key={cat.label}>
|
<div key={cat.label}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<h3 className={`text-xs font-bold uppercase tracking-widest ${cat.accent}`}>
|
<h3 className={`text-xs font-semibold uppercase tracking-widest ${cat.accent}`}>
|
||||||
{cat.emoji} {cat.label}
|
{cat.emoji} {cat.label}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-white/5" />
|
<div className="flex-1 h-px bg-white/5" />
|
||||||
@@ -88,11 +88,11 @@ export default function BookmarksPage() {
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="card p-4 hover:-translate-y-1 transition-all duration-200 group"
|
className="card group p-4 hover:border-white/15"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xl">{link.emoji}</span>
|
<span className="text-xl">{link.emoji}</span>
|
||||||
<div className="text-sm font-medium text-white truncate group-hover:text-indigo-300 transition-colors">
|
<div className="text-sm font-medium text-slate-200 group-hover:gradient-text transition-all truncate">
|
||||||
{link.name}
|
{link.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { auth } from "@/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { Sidebar } from "@/components/layout/Sidebar";
|
import { Sidebar } from "@/components/layout/Sidebar";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen" style={{ background: "#0a0a0f" }}>
|
<div className="flex h-screen bg-[#080810] overflow-hidden">
|
||||||
<Sidebar userName={session.user?.name} />
|
<Sidebar />
|
||||||
<main className="flex-1 ml-64 p-6 min-h-screen" style={{ background: "#0a0a0f" }}>
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-6 max-w-[1600px] mx-auto">
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
import { WeatherWidget } from "@/components/widgets/WeatherWidget";
|
import { WeatherWidget } from "@/components/widgets/WeatherWidget";
|
||||||
import { TasksWidget } from "@/components/widgets/TasksWidget";
|
|
||||||
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
||||||
|
import { TasksWidget } from "@/components/widgets/TasksWidget";
|
||||||
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 { ServicesGrid } from "@/components/widgets/ServicesGrid";
|
||||||
import { DashboardHeader } from "@/components/widgets/DashboardHeader";
|
import { DashboardHeader } from "@/components/widgets/DashboardHeader";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-5">
|
||||||
<DashboardHeader />
|
<DashboardHeader />
|
||||||
|
|
||||||
{/* Weather - full width */}
|
{/* Weather - full width */}
|
||||||
<WeatherWidget />
|
<WeatherWidget />
|
||||||
|
|
||||||
{/* Calendar + Tasks */}
|
{/* Calendar + Tasks + Claude */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<CalendarWidget />
|
<CalendarWidget />
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-1">
|
<div className="space-y-5">
|
||||||
<TasksWidget />
|
<TasksWidget />
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-5 lg:grid-cols-1">
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Claude row */}
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
|
||||||
<ClaudeUsageWidget />
|
<ClaudeUsageWidget />
|
||||||
<ClaudeApiWidget />
|
<ClaudeApiWidget />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<ServicesGrid />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const TABS = ["Openclaw", "Сервисы"] as const;
|
|||||||
type TabName = typeof TABS[number];
|
type TabName = typeof TABS[number];
|
||||||
|
|
||||||
const MACHINE_MAP: Record<TabName, string> = {
|
const MACHINE_MAP: Record<TabName, string> = {
|
||||||
"Openclaw": "openclaw",
|
"Openclaw": "ocplatform",
|
||||||
"Сервисы": "services",
|
"Сервисы": "services",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,13 +29,20 @@ function CircularGauge({ value, size = 96, color, label }: { value: number; size
|
|||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="relative" style={{ width: size, height: size }}>
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
<svg width={size} height={size} className="-rotate-90">
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`gauge-gradient-${label}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity="0.9" />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity="0.4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="10" />
|
<circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="10" />
|
||||||
<circle
|
<circle
|
||||||
cx={size/2} cy={size/2} r={radius}
|
cx={size/2} cy={size/2} r={radius}
|
||||||
fill="none" stroke={color}
|
fill="none"
|
||||||
|
stroke={`url(#gauge-gradient-${label})`}
|
||||||
strokeWidth="10" strokeLinecap="round"
|
strokeWidth="10" strokeLinecap="round"
|
||||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||||
style={{ transition: "stroke-dashoffset 0.8s ease", filter: `drop-shadow(0 0 6px ${color}60)` }}
|
style={{ transition: "stroke-dashoffset 0.8s ease", filter: `drop-shadow(0 0 8px ${color}60)` }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -54,7 +61,7 @@ function UsageBar({ label, value, color, detail }: { label: string; value: numbe
|
|||||||
<span className="text-slate-400 font-medium">{label}</span>
|
<span className="text-slate-400 font-medium">{label}</span>
|
||||||
<span className="text-white font-semibold">{detail || `${value}%`}</span>
|
<span className="text-white font-semibold">{detail || `${value}%`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "rgba(255,255,255,0.05)" }}>
|
<div className="h-1.5 rounded-full overflow-hidden bg-white/5">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-700"
|
className="h-full rounded-full transition-all duration-700"
|
||||||
style={{ width: `${value}%`, background: color, boxShadow: `0 0 8px ${color}80` }}
|
style={{ width: `${value}%`, background: color, boxShadow: `0 0 8px ${color}80` }}
|
||||||
@@ -102,19 +109,19 @@ export default function SystemPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-5 max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between pt-2">
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white">System Monitor</h1>
|
<h1 className="text-2xl font-bold text-white">System Monitor</h1>
|
||||||
<p className="text-slate-500 text-sm mt-1">
|
<p className="text-slate-500 text-sm mt-0.5">
|
||||||
{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-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"
|
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 bg-white/3"
|
||||||
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" : ""}`} />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -122,14 +129,14 @@ export default function SystemPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 p-1 rounded-xl w-fit border border-white/5" style={{ background: "rgba(255,255,255,0.03)" }}>
|
<div className="flex gap-1 p-1 rounded-2xl w-fit bg-white/5">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
|
className={`px-5 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? "text-white"
|
? "text-white shadow-lg shadow-indigo-500/20"
|
||||||
: "text-slate-500 hover:text-slate-300"
|
: "text-slate-500 hover:text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
style={activeTab === tab ? { background: "linear-gradient(135deg, #6366f1, #8b5cf6)" } : {}}
|
style={activeTab === tab ? { background: "linear-gradient(135deg, #6366f1, #8b5cf6)" } : {}}
|
||||||
@@ -150,9 +157,11 @@ export default function SystemPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Circular gauges */}
|
{/* Circular gauges */}
|
||||||
<div className="card card-accent-violet p-6">
|
<div className="card card-violet p-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<Activity className="w-4 h-4 text-violet-400" />
|
<div className="w-7 h-7 rounded-lg bg-violet-500/15 flex items-center justify-center">
|
||||||
|
<Activity className="w-3.5 h-3.5 text-violet-400" />
|
||||||
|
</div>
|
||||||
<span className="text-sm font-semibold text-white">Загрузка системы</span>
|
<span className="text-sm font-semibold text-white">Загрузка системы</span>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -171,10 +180,10 @@ 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, label: "CPU", value: loading ? "..." : `${metrics?.cpu ?? "—"}%`, sub: loading ? "" : `Load: ${metrics?.load1?.toFixed(2) ?? "—"} · ${metrics?.cpuCount ?? "?"} cores`, color: "text-indigo-400", accent: "card-blue" },
|
||||||
{ icon: MemoryStick, label: "RAM", value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`, color: "text-violet-400", accent: "card-accent-violet" },
|
{ icon: MemoryStick, label: "RAM", value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`, color: "text-violet-400", accent: "card-violet" },
|
||||||
{ icon: HardDrive, label: "Disk", value: loading ? "..." : `${metrics?.disk?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.disk?.total ?? "?"} GB`, color: "text-emerald-400", accent: "card-accent-emerald" },
|
{ icon: HardDrive, label: "Disk", value: loading ? "..." : `${metrics?.disk?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.disk?.total ?? "?"} GB`, color: "text-emerald-400", accent: "card-emerald" },
|
||||||
{ icon: Clock, label: "Uptime", value: loading ? "..." : (metrics?.uptime ?? "—"), sub: "Время работы", color: "text-amber-400", accent: "card-accent-amber" },
|
{ icon: Clock, label: "Uptime", value: loading ? "..." : (metrics?.uptime ?? "—"), sub: "Время работы", color: "text-amber-400", accent: "card-amber" },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className={`card ${item.accent} 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">
|
||||||
@@ -203,9 +212,11 @@ export default function SystemPage() {
|
|||||||
|
|
||||||
{/* Network */}
|
{/* Network */}
|
||||||
{!loading && metrics?.network && (
|
{!loading && metrics?.network && (
|
||||||
<div className="card card-accent-cyan p-5">
|
<div className="card card-cyan p-5">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Wifi className="w-4 h-4 text-cyan-400" />
|
<div className="w-7 h-7 rounded-lg bg-cyan-500/15 flex items-center justify-center">
|
||||||
|
<Wifi className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
<span className="text-sm font-semibold text-white">Сеть (всего)</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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -24,20 +24,15 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--ring: 240 6% 12%;
|
--ring: 240 6% 12%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
--bg-base: #0a0a0f;
|
|
||||||
--bg-surface: #111118;
|
|
||||||
--bg-elevated: #1a1a24;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* { @apply border-border; }
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-base);
|
background-color: #080810;
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
color: #f8fafc;
|
color: #f1f5f9;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,38 +40,46 @@
|
|||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.3); border-radius: 2px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
::-webkit-scrollbar-thumb:hover { background: rgba(99,102,241,0.5); }
|
||||||
|
|
||||||
/* Card base style */
|
/* Card base */
|
||||||
.card {
|
.card {
|
||||||
background: rgba(255,255,255,0.03);
|
background: rgba(255,255,255,0.03);
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
backdrop-filter: blur(10px);
|
transition: all 0.25s ease;
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
border-color: rgba(255,255,255,0.1);
|
border-color: rgba(255,255,255,0.12);
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy compat */
|
/* Legacy compat */
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: rgba(255,255,255,0.03);
|
background: rgba(255,255,255,0.03);
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.25s ease;
|
||||||
}
|
}
|
||||||
.glass-card:hover {
|
.glass-card:hover {
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
border-color: rgba(255,255,255,0.1);
|
border-color: rgba(255,255,255,0.12);
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accent borders — short names */
|
||||||
|
.card-blue { border-top: 2px solid #3b82f6; }
|
||||||
|
.card-violet { border-top: 2px solid #8b5cf6; }
|
||||||
|
.card-emerald { border-top: 2px solid #10b981; }
|
||||||
|
.card-amber { border-top: 2px solid #f59e0b; }
|
||||||
|
.card-cyan { border-top: 2px solid #06b6d4; }
|
||||||
|
.card-rose { border-top: 2px solid #f43f5e; }
|
||||||
|
|
||||||
|
/* Legacy accent names */
|
||||||
.card-accent-blue { border-top: 2px solid #3b82f6; }
|
.card-accent-blue { border-top: 2px solid #3b82f6; }
|
||||||
.card-accent-violet { border-top: 2px solid #8b5cf6; }
|
.card-accent-violet { border-top: 2px solid #8b5cf6; }
|
||||||
.card-accent-emerald { border-top: 2px solid #10b981; }
|
.card-accent-emerald { border-top: 2px solid #10b981; }
|
||||||
@@ -86,36 +89,30 @@
|
|||||||
|
|
||||||
/* Gradient text */
|
/* Gradient text */
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
background: linear-gradient(135deg, #818cf8, #c084fc);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.status-online { background: #10b981; box-shadow: 0 0 6px rgba(16,185,129,0.5); }
|
||||||
|
.status-offline { background: #ef4444; box-shadow: 0 0 6px rgba(239,68,68,0.5); }
|
||||||
|
.status-checking { background: #6b7280; }
|
||||||
|
|
||||||
|
/* Pulsing status */
|
||||||
|
@keyframes ping-slow {
|
||||||
|
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||||
|
50% { opacity: 0.3; transform: scale(1.5); }
|
||||||
|
}
|
||||||
|
.ping-slow { animation: ping-slow 2s ease-in-out infinite; }
|
||||||
|
|
||||||
/* Glow effects */
|
/* Glow effects */
|
||||||
.glow-violet { box-shadow: 0 0 30px rgba(139,92,246,0.2); }
|
.glow-violet { box-shadow: 0 0 40px rgba(139,92,246,0.15); }
|
||||||
.glow-blue { box-shadow: 0 0 30px rgba(59,130,246,0.2); }
|
.glow-blue { box-shadow: 0 0 40px rgba(59,130,246,0.15); }
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: #0d0d14;
|
background: #0a0a14;
|
||||||
border-right: 1px solid rgba(255,255,255,0.05);
|
border-right: 1px solid rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status indicators */
|
|
||||||
.status-online {
|
|
||||||
background: #10b981;
|
|
||||||
box-shadow: 0 0 6px rgba(16,185,129,0.5);
|
|
||||||
}
|
|
||||||
.status-offline {
|
|
||||||
background: #ef4444;
|
|
||||||
box-shadow: 0 0 6px rgba(239,68,68,0.5);
|
|
||||||
}
|
|
||||||
.status-checking {
|
|
||||||
background: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes status-ring {
|
|
||||||
0%, 100% { opacity: 0.7; transform: scale(1); }
|
|
||||||
50% { opacity: 0; transform: scale(2.5); }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,85 +1,74 @@
|
|||||||
"use client";
|
"use client";
|
||||||
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, useSession } from "next-auth/react";
|
||||||
import { LayoutDashboard, Server, Bookmark, LogOut } from "lucide-react";
|
import { LayoutDashboard, Monitor, Bookmark, LogOut, Home } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const navItems = [
|
const NAV = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ href: "/system", label: "System", icon: Server },
|
{ href: "/system", icon: Monitor, label: "System" },
|
||||||
{ href: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
{ href: "/bookmarks", icon: Bookmark, label: "Bookmarks" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ userName }: { userName?: string | null }) {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar w-64 min-h-screen flex flex-col fixed left-0 top-0 z-50">
|
<aside className="w-[220px] flex-shrink-0 flex flex-col h-screen bg-[#0a0a14] border-r border-white/5">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-5 border-b border-white/5">
|
<div className="px-5 py-6 border-b border-white/5">
|
||||||
<Link href="/" className="flex items-center gap-3 group">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold text-white flex-shrink-0"
|
<Home className="w-4 h-4 text-white" />
|
||||||
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-[10px] text-slate-500 uppercase tracking-wider">Dashboard</div>
|
<div className="text-[10px] text-slate-500">Dashboard</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 p-3 space-y-0.5 mt-2">
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
<div className="px-3 mb-2">
|
{NAV.map(({ href, icon: Icon, label }) => {
|
||||||
<span className="text-[10px] font-semibold text-slate-600 uppercase tracking-widest">Navigation</span>
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
</div>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={href}
|
||||||
href={item.href}
|
href={href}
|
||||||
className={cn(
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
|
active
|
||||||
isActive
|
? "bg-indigo-500/15 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-500 hover:text-slate-300 hover:bg-white/5 border border-transparent"
|
||||||
: "text-slate-500 hover:text-slate-300 hover:bg-white/5"
|
}`}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon className={cn("w-4 h-4 shrink-0", isActive ? "text-indigo-400" : "")} />
|
<Icon className={`w-4 h-4 ${active ? "text-indigo-400" : ""}`} />
|
||||||
{item.label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User section */}
|
{/* User */}
|
||||||
<div className="p-3 border-t border-white/5">
|
<div className="px-3 py-4 border-t border-white/5">
|
||||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl mb-1">
|
<div className="flex items-center gap-3 px-3 py-2 rounded-xl" style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||||
<div
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center text-xs font-bold text-white flex-shrink-0">
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
|
D
|
||||||
style={{ background: "linear-gradient(135deg, #6366f1, #8b5cf6)" }}
|
|
||||||
>
|
|
||||||
{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-xs font-medium text-slate-300 truncate">{session?.user?.name ?? "Daniil"}</div>
|
||||||
<div className="text-[10px] text-slate-500">Administrator</div>
|
<div className="text-[10px] text-slate-600 truncate">Admin</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-xl text-sm text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
className="text-slate-600 hover:text-red-400 transition-colors"
|
||||||
|
title="Выйти"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
Выйти
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,15 @@ export function ClaudeApiWidget() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card card-accent-violet p-5">
|
<div className="card card-violet p-5">
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
|
<div className="w-7 h-7 rounded-lg bg-violet-500/15 flex items-center justify-center">
|
||||||
style={{ background: "linear-gradient(135deg, #8b5cf6, #6366f1)" }}>
|
<span className="text-sm">🔮</span>
|
||||||
A
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-white">Claude API</div>
|
<div className="text-sm font-semibold text-white">Claude API</div>
|
||||||
<div className="text-[10px] text-slate-500">Usage & Cost</div>
|
<div className="text-[10px] text-slate-500">Cost & Usage</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
<button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||||
@@ -28,22 +27,22 @@ export function ClaudeApiWidget() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ label: "Стоимость", value: "—", color: "text-violet-400" },
|
{ label: "Стоимость", value: "—", color: "text-violet-400" },
|
||||||
{ label: "Запросы", value: "—", color: "text-white" },
|
{ label: "Запросы", value: "—", color: "text-white" },
|
||||||
{ label: "Токены", value: "—", color: "text-white" },
|
{ label: "Токены", value: "—", color: "text-white" },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="rounded-xl p-3 text-center border border-white/5" style={{ background: "rgba(255,255,255,0.03)" }}>
|
<div key={item.label} className="flex justify-between items-center">
|
||||||
<div className={`text-xl font-bold ${item.color}`}>{item.value}</div>
|
<span className="text-xs text-slate-500">{item.label}</span>
|
||||||
<div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
|
<span className={`text-xs font-medium ${item.color}`}>{item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-600">
|
<div className="mt-4 flex items-center gap-2 text-xs text-slate-600">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-violet-500/50" />
|
<div className="w-1.5 h-1.5 rounded-full bg-violet-500/50" />
|
||||||
<span>Автоматическое получение данных недоступно</span>
|
<span>Автополучение недоступно</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,15 +11,14 @@ export function ClaudeUsageWidget() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card card-accent-amber p-5">
|
<div className="card card-amber p-5">
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
|
<div className="w-7 h-7 rounded-lg bg-amber-500/15 flex items-center justify-center">
|
||||||
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}>
|
<span className="text-sm">✨</span>
|
||||||
C
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-white">Claude Subscription</div>
|
<div className="text-sm font-semibold text-white">Claude Sub</div>
|
||||||
<div className="text-[10px] text-slate-500">Usage</div>
|
<div className="text-[10px] text-slate-500">Usage</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,22 +27,22 @@ export function ClaudeUsageWidget() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ label: "Использовано", value: "—", color: "text-amber-400" },
|
{ label: "Использовано", value: "—", color: "text-amber-400" },
|
||||||
{ label: "Лимит", value: "—", color: "text-white" },
|
{ label: "Лимит", value: "—", color: "text-white" },
|
||||||
{ label: "Сброс", value: "—", color: "text-white" },
|
{ label: "Сброс", value: "—", color: "text-white" },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="rounded-xl p-3 text-center border border-white/5" style={{ background: "rgba(255,255,255,0.03)" }}>
|
<div key={item.label} className="flex justify-between items-center">
|
||||||
<div className={`text-xl font-bold ${item.color}`}>{item.value}</div>
|
<span className="text-xs text-slate-500">{item.label}</span>
|
||||||
<div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
|
<span className={`text-xs font-medium ${item.color}`}>{item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-600">
|
<div className="mt-4 flex items-center gap-2 text-xs text-slate-600">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500/50" />
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-500/50" />
|
||||||
<span>Автоматическое получение данных недоступно</span>
|
<span>Автополучение недоступно</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const MONTHS = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
|
||||||
|
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||||
|
|
||||||
export function DashboardHeader() {
|
export function DashboardHeader() {
|
||||||
const [time, setTime] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const i = setInterval(() => setTime(new Date()), 1000);
|
const i = setInterval(() => setNow(new Date()), 1000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hour = time.getHours();
|
const hour = now.getHours();
|
||||||
const greeting =
|
const greeting = hour < 6 ? "Доброй ночи" : hour < 12 ? "Доброе утро" : hour < 17 ? "Добрый день" : "Добрый вечер";
|
||||||
hour < 6 ? "Доброй ночи" :
|
|
||||||
hour < 12 ? "Доброе утро" :
|
|
||||||
hour < 17 ? "Добрый день" :
|
|
||||||
hour < 22 ? "Добрый вечер" :
|
|
||||||
"Доброй ночи";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between pt-2 pb-2">
|
<div className="flex items-start justify-between pt-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<h1 className="text-2xl font-bold text-white">
|
||||||
{greeting},{" "}
|
{greeting}, <span className="gradient-text">Daniil</span> 👋
|
||||||
<span className="gradient-text">Daniil</span>{" "}
|
|
||||||
<span>👋</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500 mt-1 text-sm">
|
<p className="text-slate-500 text-sm mt-0.5">
|
||||||
{time.toLocaleDateString("ru-RU", {
|
{DAYS[now.getDay()]}, {now.getDate()} {MONTHS[now.getMonth()]} {now.getFullYear()}
|
||||||
weekday: "long",
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
{" · "}
|
|
||||||
{time.toLocaleTimeString("ru-RU", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-right">
|
||||||
<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">
|
<div className="text-2xl font-light text-white tabular-nums">
|
||||||
digital-home.site
|
{now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{now.toLocaleTimeString("ru-RU", { second: "2-digit" })} сек
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,36 +85,44 @@ function ServiceCard({ service }: { service: Service }) {
|
|||||||
href={service.url}
|
href={service.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="card p-4 flex items-center gap-3 hover:-translate-y-1 transition-all duration-200 group"
|
className="card group flex items-center gap-3 p-4 hover:border-white/15"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-xl border border-white/5 flex items-center justify-center shrink-0 text-xl"
|
{/* Icon */}
|
||||||
style={{ background: "rgba(255,255,255,0.05)" }}>
|
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-xl flex-shrink-0 group-hover:bg-white/10 transition-colors">
|
||||||
{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 rounded"
|
||||||
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
const parent = (e.target as HTMLImageElement).parentElement;
|
const parent = (e.target as HTMLImageElement).parentElement;
|
||||||
if (parent) parent.textContent = service.emoji ?? "🔗";
|
if (parent) parent.textContent = service.emoji ?? "🔗";
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span>{service.emoji ?? "🔗"}</span>
|
<span>{service.emoji ?? "🔗"}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<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-slate-200 group-hover:text-white transition-colors truncate">
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
<ExternalLink className="w-3 h-3 text-slate-700 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-600 truncate mt-0.5">{service.desc}</p>
|
<p className="text-xs text-slate-600 truncate mt-0.5">{service.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 relative">
|
{/* Status */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
{status === "online" && (
|
{status === "online" && (
|
||||||
<span className="absolute inset-0 rounded-full bg-emerald-400 opacity-40 animate-ping" />
|
<span className="absolute inset-0 rounded-full bg-emerald-400 ping-slow" />
|
||||||
)}
|
)}
|
||||||
<span className={`relative block w-2 h-2 rounded-full ${
|
<span className={`relative block w-2 h-2 rounded-full ${
|
||||||
status === "online" ? "bg-emerald-500" :
|
status === "online" ? "bg-emerald-400" :
|
||||||
status === "offline" ? "bg-red-500" :
|
status === "offline" ? "bg-red-400" : "bg-slate-600 animate-pulse"
|
||||||
"bg-slate-600 animate-pulse"
|
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -123,15 +131,17 @@ function ServiceCard({ service }: { service: Service }) {
|
|||||||
|
|
||||||
export function ServicesGrid() {
|
export function ServicesGrid() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<h2 className="text-lg font-bold text-white">Сервисы</h2>
|
<h2 className="text-lg font-bold text-white">Сервисы</h2>
|
||||||
<div className="flex-1 h-px bg-white/5" />
|
<div className="flex-1 h-px bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
{SERVICE_CATEGORIES.map((cat) => (
|
{SERVICE_CATEGORIES.map((cat) => (
|
||||||
<div key={cat.label}>
|
<div key={cat.label}>
|
||||||
<h3 className={`text-xs font-bold uppercase tracking-widest mb-3 ${cat.accent}`}>{cat.label}</h3>
|
<h3 className={`text-xs font-semibold uppercase tracking-widest mb-3 mt-6 first:mt-0 ${cat.accent}`}>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
{cat.label}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
{cat.services.map((svc) => (
|
{cat.services.map((svc) => (
|
||||||
<ServiceCard key={svc.url} service={svc} />
|
<ServiceCard key={svc.url} service={svc} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ export function TasksWidget() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card card-accent-emerald p-5">
|
<div className="card card-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" />
|
<div className="w-7 h-7 rounded-lg bg-emerald-500/15 flex items-center justify-center">
|
||||||
|
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
<span className="text-sm font-semibold text-white">Задачи</span>
|
<span className="text-sm font-semibold text-white">Задачи</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={fetchTasks} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
<button onClick={fetchTasks} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||||
@@ -59,7 +61,8 @@ export function TasksWidget() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5 max-h-[320px] 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.5 p-2.5 rounded-xl hover:bg-white/3 transition-colors group">
|
<div key={task.id} className="flex items-start gap-2.5 p-2.5 rounded-xl bg-white/3 hover:bg-white/5 transition-colors group">
|
||||||
|
<div className="w-0.5 self-stretch rounded-full bg-gradient-to-b from-emerald-400 to-teal-500 shrink-0" />
|
||||||
{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" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function WeatherWidget() {
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetch_ = async () => {
|
||||||
setLoading(true); setError(false);
|
setLoading(true); setError(false);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/weather");
|
const res = await fetch("/api/weather");
|
||||||
@@ -31,38 +31,16 @@ export function WeatherWidget() {
|
|||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, []);
|
useEffect(() => { fetch_(); }, []);
|
||||||
|
|
||||||
const c = data?.current;
|
|
||||||
const day = data?.forecast?.[selected];
|
|
||||||
|
|
||||||
const formatDay = (dateStr: string, idx: number) => {
|
|
||||||
if (idx === 0) return "Сег";
|
|
||||||
const d = new Date(dateStr + "T12:00:00");
|
|
||||||
return DAY_NAMES[d.getDay()];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card card-accent-blue relative overflow-hidden">
|
<div className="card card-blue relative overflow-hidden">
|
||||||
{/* Decorative background */}
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-950/40 via-transparent to-indigo-950/30 pointer-events-none" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/20 via-transparent to-violet-900/10 pointer-events-none" />
|
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-500/5 rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||||
|
|
||||||
<div className="relative p-6">
|
<div className="relative p-6">
|
||||||
{/* Header */}
|
{loading ? (
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="animate-pulse space-y-4">
|
||||||
<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>
|
|
||||||
|
|
||||||
{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="flex items-end gap-4">
|
||||||
<div className="h-20 w-20 bg-white/5 rounded-2xl" />
|
<div className="h-20 w-20 bg-white/5 rounded-2xl" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -70,62 +48,78 @@ export function WeatherWidget() {
|
|||||||
<div className="h-4 w-40 bg-white/5 rounded" />
|
<div className="h-4 w-40 bg-white/5 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</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 className="flex gap-2">{[...Array(7)].map((_,i) => <div key={i} className="flex-1 h-24 bg-white/5 rounded-2xl" />)}</div>
|
||||||
</div>
|
</div>
|
||||||
) : c && day ? (
|
) : error ? (
|
||||||
<>
|
<div className="text-slate-500 text-sm py-8 text-center">Нет данных о погоде</div>
|
||||||
{/* Current weather */}
|
) : data && (
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="space-y-5">
|
||||||
|
{/* Current */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline gap-3 mb-1">
|
<p className="text-slate-400 text-xs font-medium uppercase tracking-wider mb-2">Санкт-Петербург</p>
|
||||||
<span className="text-7xl font-extralight text-white leading-none">
|
<div className="flex items-start gap-3">
|
||||||
{selected === 0 ? c.temp : day.maxTemp}°
|
<span className="text-6xl font-extralight text-white leading-none">
|
||||||
|
{selected === 0 ? data.current.temp : data.forecast[selected].maxTemp}°
|
||||||
</span>
|
</span>
|
||||||
<span className="text-4xl">{selected === 0 ? c.icon : day.icon}</span>
|
<div className="mt-1">
|
||||||
|
<span className="text-3xl">{selected === 0 ? data.current.icon : data.forecast[selected].icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-300 mt-2">{selected === 0 ? c.desc : day.desc}</p>
|
</div>
|
||||||
{selected === 0 && (
|
<p className="text-slate-300 text-sm mt-2">{selected === 0 ? data.current.desc : data.forecast[selected].desc}</p>
|
||||||
<p className="text-slate-500 text-xs mt-0.5">Ощущается как {c.feelsLike}°</p>
|
{selected !== 0 && (
|
||||||
|
<p className="text-slate-500 text-xs mt-1">мин {data.forecast[selected].minTemp}°</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selected === 0 && (
|
{selected === 0 && (
|
||||||
<div className="text-right space-y-2 mt-2">
|
<div className="flex flex-col gap-2 text-xs text-slate-400">
|
||||||
<div className="flex items-center gap-2 justify-end text-sm">
|
<div className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2.5 py-1.5">
|
||||||
<span className="text-slate-500 text-xs">Влажность</span>
|
<span>🌡️</span>
|
||||||
<span className="text-blue-400 font-medium">💧 {c.humidity}%</span>
|
<span>Ощущ. {data.current.feelsLike}°</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 justify-end text-sm">
|
<div className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2.5 py-1.5">
|
||||||
<span className="text-slate-500 text-xs">Ветер</span>
|
<span>💧</span>
|
||||||
<span className="text-cyan-400 font-medium">💨 {c.windKmh} км/ч</span>
|
<span>{data.current.humidity}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2.5 py-1.5">
|
||||||
|
<span>💨</span>
|
||||||
|
<span>{data.current.windKmh} км/ч</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={fetch_} className="flex items-center gap-1.5 bg-white/5 hover:bg-white/10 rounded-lg px-2.5 py-1.5 transition-colors">
|
||||||
|
<RefreshCw className={`w-3 h-3 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
<span>Обновить</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 7-day forecast */}
|
{/* 7-day forecast */}
|
||||||
<div className="grid grid-cols-7 gap-1.5">
|
<div className="grid grid-cols-7 gap-1.5">
|
||||||
{data!.forecast.map((f, i) => (
|
{data.forecast.map((day: DayForecast, i: number) => {
|
||||||
|
const d = new Date(day.date + "T12:00:00");
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={f.date}
|
key={day.date}
|
||||||
onClick={() => setSelected(i)}
|
onClick={() => setSelected(i)}
|
||||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all ${
|
className={`flex flex-col items-center gap-1.5 py-3 px-1 rounded-2xl transition-all ${
|
||||||
selected === i
|
selected === i
|
||||||
? "bg-white/10 ring-1 ring-white/20"
|
? "bg-white/10 ring-1 ring-white/20 shadow-lg"
|
||||||
: "hover:bg-white/5"
|
: "hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-medium text-slate-400">{formatDay(f.date, i)}</span>
|
<span className="text-[10px] font-medium text-slate-400">{i === 0 ? "Сег" : DAY_NAMES[d.getDay()]}</span>
|
||||||
<span className="text-xl">{f.icon}</span>
|
<span className="text-xl">{day.icon}</span>
|
||||||
<span className="text-xs font-semibold text-white">{f.maxTemp}°</span>
|
<span className="text-xs font-semibold text-white">{day.maxTemp}°</span>
|
||||||
<span className="text-[10px] text-slate-600">{f.minTemp}°</span>
|
<span className="text-[10px] text-slate-600">{day.minTemp}°</span>
|
||||||
{f.precipProb > 20 && (
|
{day.precipProb > 20 && (
|
||||||
<span className="text-[9px] text-blue-400 font-medium">{f.precipProb}%</span>
|
<span className="text-[9px] text-blue-400 font-medium">{day.precipProb}%</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ const config: Config = {
|
|||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
dash: {
|
dash: {
|
||||||
bg: "#0a0a0f",
|
bg: "#080810",
|
||||||
surface: "#111118",
|
surface: "#0f0f1a",
|
||||||
elevated: "#1a1a24",
|
elevated: "#161624",
|
||||||
border: "rgba(255,255,255,0.06)",
|
border: "rgba(255,255,255,0.07)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
@@ -65,6 +65,10 @@ const config: Config = {
|
|||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
|
"ping-slow": {
|
||||||
|
"0%, 100%": { opacity: "0.8", transform: "scale(1)" },
|
||||||
|
"50%": { opacity: "0.3", transform: "scale(1.5)" },
|
||||||
|
},
|
||||||
glow: {
|
glow: {
|
||||||
"0%": { boxShadow: "0 0 5px rgba(99,102,241,0.3)" },
|
"0%": { boxShadow: "0 0 5px rgba(99,102,241,0.3)" },
|
||||||
"100%": { boxShadow: "0 0 20px rgba(99,102,241,0.6)" },
|
"100%": { boxShadow: "0 0 20px rgba(99,102,241,0.6)" },
|
||||||
@@ -73,18 +77,14 @@ const config: Config = {
|
|||||||
"0%, 100%": { transform: "translateY(0px)" },
|
"0%, 100%": { transform: "translateY(0px)" },
|
||||||
"50%": { transform: "translateY(-6px)" },
|
"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",
|
||||||
|
"ping-slow": "ping-slow 2s ease-in-out infinite",
|
||||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||||
glow: "glow 2s ease-in-out infinite alternate",
|
glow: "glow 2s ease-in-out infinite alternate",
|
||||||
float: "float 6s ease-in-out infinite",
|
float: "float 6s ease-in-out infinite",
|
||||||
"status-pulse": "status-pulse 2s ease-in-out infinite",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user