redesign: modern dark dashboard WOW effect - gradients, animations, new layout
This commit is contained in:
@@ -67,16 +67,16 @@ export default function BookmarksPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
<div className="pt-2 pb-2">
|
||||
<h1 className="text-3xl font-bold text-white">Bookmarks</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">Все ссылки в одном месте</p>
|
||||
<div className="space-y-5 max-w-7xl mx-auto">
|
||||
<div className="pt-1 pb-1">
|
||||
<h1 className="text-2xl font-bold text-white">Bookmarks</h1>
|
||||
<p className="text-slate-500 text-sm mt-0.5">Все ссылки в одном месте</p>
|
||||
</div>
|
||||
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.label}>
|
||||
<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}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
@@ -88,11 +88,11 @@ export default function BookmarksPage() {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
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">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen" style={{ background: "#0a0a0f" }}>
|
||||
<Sidebar userName={session.user?.name} />
|
||||
<main className="flex-1 ml-64 p-6 min-h-screen" style={{ background: "#0a0a0f" }}>
|
||||
<div className="flex h-screen bg-[#080810] overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 max-w-[1600px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { WeatherWidget } from "@/components/widgets/WeatherWidget";
|
||||
import { TasksWidget } from "@/components/widgets/TasksWidget";
|
||||
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
||||
import { TasksWidget } from "@/components/widgets/TasksWidget";
|
||||
import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
|
||||
import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget";
|
||||
import { ServicesGrid } from "@/components/widgets/ServicesGrid";
|
||||
import { DashboardHeader } from "@/components/widgets/DashboardHeader";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
<div className="space-y-5">
|
||||
<DashboardHeader />
|
||||
|
||||
{/* Weather - full width */}
|
||||
<WeatherWidget />
|
||||
|
||||
{/* Calendar + Tasks */}
|
||||
{/* Calendar + Tasks + Claude */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div className="lg:col-span-2">
|
||||
<CalendarWidget />
|
||||
</div>
|
||||
<div className="lg:col-span-1">
|
||||
<div className="space-y-5">
|
||||
<TasksWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude row */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-1">
|
||||
<ClaudeUsageWidget />
|
||||
<ClaudeApiWidget />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<ServicesGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const TABS = ["Openclaw", "Сервисы"] as const;
|
||||
type TabName = typeof TABS[number];
|
||||
|
||||
const MACHINE_MAP: Record<TabName, string> = {
|
||||
"Openclaw": "openclaw",
|
||||
"Openclaw": "ocplatform",
|
||||
"Сервисы": "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="relative" style={{ width: size, height: size }}>
|
||||
<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={color}
|
||||
fill="none"
|
||||
stroke={`url(#gauge-gradient-${label})`}
|
||||
strokeWidth="10" strokeLinecap="round"
|
||||
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>
|
||||
<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-white font-semibold">{detail || `${value}%`}</span>
|
||||
</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
|
||||
className="h-full rounded-full transition-all duration-700"
|
||||
style={{ width: `${value}%`, background: color, boxShadow: `0 0 8px ${color}80` }}
|
||||
@@ -102,19 +109,19 @@ export default function SystemPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="space-y-5 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">System Monitor</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
<h1 className="text-2xl font-bold text-white">System Monitor</h1>
|
||||
<p className="text-slate-500 text-sm mt-0.5">
|
||||
{lastUpdated ? `Обновлено: ${lastUpdated.toLocaleTimeString("ru-RU")}` : "Загрузка..."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
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"
|
||||
style={{ background: "rgba(255,255,255,0.03)" }}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
@@ -122,14 +129,14 @@ export default function SystemPage() {
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={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
|
||||
? "text-white"
|
||||
? "text-white shadow-lg shadow-indigo-500/20"
|
||||
: "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
style={activeTab === tab ? { background: "linear-gradient(135deg, #6366f1, #8b5cf6)" } : {}}
|
||||
@@ -150,9 +157,11 @@ export default function SystemPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* 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">
|
||||
<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>
|
||||
</div>
|
||||
{loading ? (
|
||||
@@ -171,10 +180,10 @@ export default function SystemPage() {
|
||||
{/* Stats grid */}
|
||||
<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: MemoryStick, label: "RAM", value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`, color: "text-violet-400", accent: "card-accent-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: Clock, label: "Uptime", value: loading ? "..." : (metrics?.uptime ?? "—"), sub: "Время работы", color: "text-amber-400", accent: "card-accent-amber" },
|
||||
{ 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-violet" },
|
||||
{ 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-amber" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className={`card ${item.accent} p-5`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
@@ -203,9 +212,11 @@ export default function SystemPage() {
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</div>
|
||||
<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 components;
|
||||
@@ -24,20 +24,15 @@
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 240 6% 12%;
|
||||
--radius: 0.75rem;
|
||||
--bg-base: #0a0a0f;
|
||||
--bg-surface: #111118;
|
||||
--bg-elevated: #1a1a24;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
* { @apply border-border; }
|
||||
body {
|
||||
background-color: var(--bg-base);
|
||||
background-color: #080810;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #f8fafc;
|
||||
color: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -45,38 +40,46 @@
|
||||
/* 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); }
|
||||
::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.3); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(99,102,241,0.5); }
|
||||
|
||||
/* Card base style */
|
||||
/* Card base */
|
||||
.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;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 20px;
|
||||
transition: all 0.25s 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);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
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 */
|
||||
.glass-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.glass-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);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
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-violet { border-top: 2px solid #8b5cf6; }
|
||||
.card-accent-emerald { border-top: 2px solid #10b981; }
|
||||
@@ -86,36 +89,30 @@
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
background: linear-gradient(135deg, #818cf8, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
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-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); }
|
||||
.glow-violet { box-shadow: 0 0 40px rgba(139,92,246,0.15); }
|
||||
.glow-blue { box-shadow: 0 0 40px rgba(59,130,246,0.15); }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background: #0d0d14;
|
||||
background: #0a0a14;
|
||||
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";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { LayoutDashboard, Server, Bookmark, LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { LayoutDashboard, Monitor, Bookmark, LogOut, Home } from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/system", label: "System", icon: Server },
|
||||
{ href: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
||||
const NAV = [
|
||||
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ href: "/system", icon: Monitor, label: "System" },
|
||||
{ href: "/bookmarks", icon: Bookmark, label: "Bookmarks" },
|
||||
];
|
||||
|
||||
export function Sidebar({ userName }: { userName?: string | null }) {
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
|
||||
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 */}
|
||||
<div className="p-5 border-b border-white/5">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div
|
||||
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 className="px-5 py-6 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
<Home className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<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) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV.map(({ href, icon: Icon, label }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
|
||||
isActive
|
||||
? "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"
|
||||
)}
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
active
|
||||
? "bg-indigo-500/15 text-indigo-300 border border-indigo-500/20"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-white/5 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 shrink-0", isActive ? "text-indigo-400" : "")} />
|
||||
{item.label}
|
||||
<Icon className={`w-4 h-4 ${active ? "text-indigo-400" : ""}`} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="p-3 border-t border-white/5">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl mb-1">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
|
||||
style={{ background: "linear-gradient(135deg, #6366f1, #8b5cf6)" }}
|
||||
>
|
||||
{userName?.[0]?.toUpperCase() ?? "D"}
|
||||
{/* User */}
|
||||
<div className="px-3 py-4 border-t border-white/5">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-xl" style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||
<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">
|
||||
D
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{userName ?? "Daniil"}</div>
|
||||
<div className="text-[10px] text-slate-500">Administrator</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-300 truncate">{session?.user?.name ?? "Daniil"}</div>
|
||||
<div className="text-[10px] text-slate-600 truncate">Admin</div>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,16 +11,15 @@ export function ClaudeApiWidget() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-accent-violet p-5">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
|
||||
style={{ background: "linear-gradient(135deg, #8b5cf6, #6366f1)" }}>
|
||||
A
|
||||
<div className="card card-violet p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-500/15 flex items-center justify-center">
|
||||
<span className="text-sm">🔮</span>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: "Стоимость", value: "—", color: "text-violet-400" },
|
||||
{ label: "Запросы", value: "—", color: "text-white" },
|
||||
{ label: "Токены", value: "—", color: "text-white" },
|
||||
].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 className={`text-xl font-bold ${item.color}`}>{item.value}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
|
||||
<div key={item.label} className="flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">{item.label}</span>
|
||||
<span className={`text-xs font-medium ${item.color}`}>{item.value}</span>
|
||||
</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" />
|
||||
<span>Автоматическое получение данных недоступно</span>
|
||||
<span>Автополучение недоступно</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,15 +11,14 @@ export function ClaudeUsageWidget() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-accent-amber p-5">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-xl flex items-center justify-center text-sm font-bold text-white"
|
||||
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}>
|
||||
C
|
||||
<div className="card card-amber p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-amber-500/15 flex items-center justify-center">
|
||||
<span className="text-sm">✨</span>
|
||||
</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>
|
||||
</div>
|
||||
@@ -28,22 +27,22 @@ export function ClaudeUsageWidget() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: "Использовано", value: "—", color: "text-amber-400" },
|
||||
{ label: "Лимит", value: "—", color: "text-white" },
|
||||
{ label: "Сброс", value: "—", color: "text-white" },
|
||||
].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 className={`text-xl font-bold ${item.color}`}>{item.value}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">{item.label}</div>
|
||||
<div key={item.label} className="flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">{item.label}</span>
|
||||
<span className={`text-xs font-medium ${item.color}`}>{item.value}</span>
|
||||
</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" />
|
||||
<span>Автоматическое получение данных недоступно</span>
|
||||
<span>Автополучение недоступно</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,48 +1,35 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const MONTHS = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
|
||||
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
const [now, setNow] = useState(new Date());
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setTime(new Date()), 1000);
|
||||
const i = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
const hour = time.getHours();
|
||||
const greeting =
|
||||
hour < 6 ? "Доброй ночи" :
|
||||
hour < 12 ? "Доброе утро" :
|
||||
hour < 17 ? "Добрый день" :
|
||||
hour < 22 ? "Добрый вечер" :
|
||||
"Доброй ночи";
|
||||
const hour = now.getHours();
|
||||
const greeting = hour < 6 ? "Доброй ночи" : hour < 12 ? "Доброе утро" : hour < 17 ? "Добрый день" : "Добрый вечер";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-2 pb-2">
|
||||
<div className="flex items-start justify-between pt-1">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{greeting},{" "}
|
||||
<span className="gradient-text">Daniil</span>{" "}
|
||||
<span>👋</span>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{greeting}, <span className="gradient-text">Daniil</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 className="text-slate-500 text-sm mt-0.5">
|
||||
{DAYS[now.getDay()]}, {now.getDate()} {MONTHS[now.getMonth()]} {now.getFullYear()}
|
||||
</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 className="text-right">
|
||||
<div className="text-2xl font-light text-white tabular-nums">
|
||||
{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>
|
||||
|
||||
@@ -85,36 +85,44 @@ function ServiceCard({ service }: { service: Service }) {
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
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"
|
||||
style={{ background: "rgba(255,255,255,0.05)" }}>
|
||||
{/* Icon */}
|
||||
<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 ? (
|
||||
// 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";
|
||||
const parent = (e.target as HTMLImageElement).parentElement;
|
||||
if (parent) parent.textContent = service.emoji ?? "🔗";
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{service.emoji ?? "🔗"}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 truncate mt-0.5">{service.desc}</p>
|
||||
</div>
|
||||
<div className="shrink-0 relative">
|
||||
{/* Status */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{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 ${
|
||||
status === "online" ? "bg-emerald-500" :
|
||||
status === "offline" ? "bg-red-500" :
|
||||
"bg-slate-600 animate-pulse"
|
||||
status === "online" ? "bg-emerald-400" :
|
||||
status === "offline" ? "bg-red-400" : "bg-slate-600 animate-pulse"
|
||||
}`} />
|
||||
</div>
|
||||
</a>
|
||||
@@ -123,15 +131,17 @@ function ServiceCard({ service }: { service: Service }) {
|
||||
|
||||
export function ServicesGrid() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h2 className="text-lg font-bold text-white">Сервисы</h2>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
</div>
|
||||
{SERVICE_CATEGORIES.map((cat) => (
|
||||
<div key={cat.label}>
|
||||
<h3 className={`text-xs font-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">
|
||||
<h3 className={`text-xs font-semibold uppercase tracking-widest mb-3 mt-6 first:mt-0 ${cat.accent}`}>
|
||||
{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) => (
|
||||
<ServiceCard key={svc.url} service={svc} />
|
||||
))}
|
||||
|
||||
@@ -36,10 +36,12 @@ export function TasksWidget() {
|
||||
};
|
||||
|
||||
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 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>
|
||||
</div>
|
||||
<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">
|
||||
{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 ? (
|
||||
<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 [selected, setSelected] = useState(0);
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetch_ = async () => {
|
||||
setLoading(true); setError(false);
|
||||
try {
|
||||
const res = await fetch("/api/weather");
|
||||
@@ -31,38 +31,16 @@ export function WeatherWidget() {
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
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()];
|
||||
};
|
||||
useEffect(() => { fetch_(); }, []);
|
||||
|
||||
return (
|
||||
<div className="card card-accent-blue relative overflow-hidden">
|
||||
{/* Decorative background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/20 via-transparent to-violet-900/10 pointer-events-none" />
|
||||
<div className="card card-blue relative overflow-hidden">
|
||||
<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 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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Погода</span>
|
||||
<span className="text-xs text-slate-500">· Санкт-Петербург</span>
|
||||
</div>
|
||||
<button onClick={fetchData} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-slate-500 text-sm text-center py-8">Нет данных о погоде</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="h-20 w-20 bg-white/5 rounded-2xl" />
|
||||
<div className="space-y-2">
|
||||
@@ -70,62 +48,78 @@ export function WeatherWidget() {
|
||||
<div className="h-4 w-40 bg-white/5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">{[...Array(7)].map((_,i) => <div key={i} className="flex-1 h-24 bg-white/5 rounded-xl" />)}</div>
|
||||
<div className="flex gap-2">{[...Array(7)].map((_,i) => <div key={i} className="flex-1 h-24 bg-white/5 rounded-2xl" />)}</div>
|
||||
</div>
|
||||
) : c && day ? (
|
||||
<>
|
||||
{/* Current weather */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
) : error ? (
|
||||
<div className="text-slate-500 text-sm py-8 text-center">Нет данных о погоде</div>
|
||||
) : data && (
|
||||
<div className="space-y-5">
|
||||
{/* Current */}
|
||||
<div className="flex items-start justify-between">
|
||||
<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}°
|
||||
<p className="text-slate-400 text-xs font-medium uppercase tracking-wider mb-2">Санкт-Петербург</p>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-6xl font-extralight text-white leading-none">
|
||||
{selected === 0 ? data.current.temp : data.forecast[selected].maxTemp}°
|
||||
</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>
|
||||
<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>
|
||||
<p className="text-slate-300 text-sm mt-2">{selected === 0 ? data.current.desc : data.forecast[selected].desc}</p>
|
||||
{selected !== 0 && (
|
||||
<p className="text-slate-500 text-xs mt-1">мин {data.forecast[selected].minTemp}°</p>
|
||||
)}
|
||||
</div>
|
||||
{selected === 0 && (
|
||||
<div className="text-right space-y-2 mt-2">
|
||||
<div className="flex items-center gap-2 justify-end text-sm">
|
||||
<span className="text-slate-500 text-xs">Влажность</span>
|
||||
<span className="text-blue-400 font-medium">💧 {c.humidity}%</span>
|
||||
<div className="flex flex-col gap-2 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2.5 py-1.5">
|
||||
<span>🌡️</span>
|
||||
<span>Ощущ. {data.current.feelsLike}°</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 className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2.5 py-1.5">
|
||||
<span>💧</span>
|
||||
<span>{data.current.humidity}%</span>
|
||||
</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>
|
||||
|
||||
{/* 7-day forecast */}
|
||||
<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
|
||||
key={f.date}
|
||||
key={day.date}
|
||||
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
|
||||
? "bg-white/10 ring-1 ring-white/20"
|
||||
? "bg-white/10 ring-1 ring-white/20 shadow-lg"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[10px] font-medium text-slate-400">{formatDay(f.date, i)}</span>
|
||||
<span className="text-xl">{f.icon}</span>
|
||||
<span className="text-xs font-semibold text-white">{f.maxTemp}°</span>
|
||||
<span className="text-[10px] text-slate-600">{f.minTemp}°</span>
|
||||
{f.precipProb > 20 && (
|
||||
<span className="text-[9px] text-blue-400 font-medium">{f.precipProb}%</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">{i === 0 ? "Сег" : DAY_NAMES[d.getDay()]}</span>
|
||||
<span className="text-xl">{day.icon}</span>
|
||||
<span className="text-xs font-semibold text-white">{day.maxTemp}°</span>
|
||||
<span className="text-[10px] text-slate-600">{day.minTemp}°</span>
|
||||
{day.precipProb > 20 && (
|
||||
<span className="text-[9px] text-blue-400 font-medium">{day.precipProb}%</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,10 +36,10 @@ const config: Config = {
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
dash: {
|
||||
bg: "#0a0a0f",
|
||||
surface: "#111118",
|
||||
elevated: "#1a1a24",
|
||||
border: "rgba(255,255,255,0.06)",
|
||||
bg: "#080810",
|
||||
surface: "#0f0f1a",
|
||||
elevated: "#161624",
|
||||
border: "rgba(255,255,255,0.07)",
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
@@ -65,6 +65,10 @@ const config: Config = {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
"ping-slow": {
|
||||
"0%, 100%": { opacity: "0.8", transform: "scale(1)" },
|
||||
"50%": { opacity: "0.3", transform: "scale(1.5)" },
|
||||
},
|
||||
glow: {
|
||||
"0%": { boxShadow: "0 0 5px rgba(99,102,241,0.3)" },
|
||||
"100%": { boxShadow: "0 0 20px rgba(99,102,241,0.6)" },
|
||||
@@ -73,18 +77,14 @@ const config: Config = {
|
||||
"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: {
|
||||
"accordion-down": "accordion-down 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",
|
||||
glow: "glow 2s ease-in-out infinite alternate",
|
||||
float: "float 6s ease-in-out infinite",
|
||||
"status-pulse": "status-pulse 2s ease-in-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user